feat: add Connector Gateway UI and MCP wiring

This commit is contained in:
4pmtong 2026-07-18 00:06:12 +08:00
parent 93d645fc6b
commit 513dd2f106
15 changed files with 1438 additions and 13 deletions

View file

@ -25,7 +25,10 @@ from app.agent.factory.remote_sub_agent import (
attach_remote_sub_agent_if_enabled,
)
from app.agent.listen_chat_agent import logger
from app.agent.prompt import BROWSER_SYS_PROMPT
from app.agent.prompt import (
BROWSER_SYS_PROMPT,
append_connected_app_mcp_notice,
)
from app.agent.toolkit.human_toolkit import HumanToolkit
from app.agent.toolkit.hybrid_browser_toolkit import HybridBrowserToolkit
@ -382,6 +385,7 @@ def browser_agent(
now_str=NOW_STR,
external_browser_notice=external_browser_notice,
)
system_message = append_connected_app_mcp_notice(system_message)
system_message = attach_remote_sub_agent_if_enabled(
options=options,
agent_name=Agents.browser_agent,

View file

@ -22,7 +22,10 @@ from app.agent.factory.remote_sub_agent import (
attach_remote_sub_agent_if_enabled,
)
from app.agent.listen_chat_agent import logger
from app.agent.prompt import DEVELOPER_SYS_PROMPT
from app.agent.prompt import (
DEVELOPER_SYS_PROMPT,
append_connected_app_mcp_notice,
)
from app.agent.toolkit.human_toolkit import HumanToolkit
# TODO: Remove NoteTakingToolkit and use TerminalToolkit instead
@ -128,6 +131,7 @@ async def developer_agent(
working_directory=working_directory,
now_str=NOW_STR,
)
system_message = append_connected_app_mcp_notice(system_message)
system_message = attach_remote_sub_agent_if_enabled(
options=options,
agent_name=Agents.developer_agent,

View file

@ -21,7 +21,10 @@ from app.agent.factory.remote_sub_agent import (
attach_remote_sub_agent_if_enabled,
)
from app.agent.listen_chat_agent import logger
from app.agent.prompt import DOCUMENT_SYS_PROMPT
from app.agent.prompt import (
DOCUMENT_SYS_PROMPT,
append_connected_app_mcp_notice,
)
from app.agent.toolkit.excel_toolkit import ExcelToolkit
from app.agent.toolkit.file_write_toolkit import FileToolkit
from app.agent.toolkit.google_drive_mcp_toolkit import GoogleDriveMCPToolkit
@ -154,6 +157,7 @@ async def document_agent(
working_directory=working_directory,
now_str=NOW_STR,
)
system_message = append_connected_app_mcp_notice(system_message)
system_message = attach_remote_sub_agent_if_enabled(
options=options,
agent_name=Agents.document_agent,

View file

@ -19,7 +19,7 @@ from app.agent.factory.remote_sub_agent import (
remote_sub_agent_enabled,
)
from app.agent.listen_chat_agent import logger
from app.agent.prompt import MCP_SYS_PROMPT
from app.agent.prompt import MCP_SYS_PROMPT, append_connected_app_mcp_notice
from app.agent.toolkit.human_toolkit import HumanToolkit
from app.agent.toolkit.mcp_search_toolkit import McpSearchToolkit
from app.agent.tools import get_mcp_tools
@ -73,7 +73,7 @@ async def mcp_agent(options: Chat):
working_directory=working_directory,
tools=tools,
tool_names=tool_names,
system_message=MCP_SYS_PROMPT,
system_message=append_connected_app_mcp_notice(MCP_SYS_PROMPT),
local_tool_description="local MCP or search tools",
message_integration=message_integration,
)

View file

@ -23,7 +23,10 @@ from app.agent.factory.remote_sub_agent import (
attach_remote_sub_agent_if_enabled,
)
from app.agent.listen_chat_agent import logger
from app.agent.prompt import MULTI_MODAL_SYS_PROMPT
from app.agent.prompt import (
MULTI_MODAL_SYS_PROMPT,
append_connected_app_mcp_notice,
)
from app.agent.toolkit.audio_analysis_toolkit import AudioAnalysisToolkit
from app.agent.toolkit.human_toolkit import HumanToolkit
@ -202,6 +205,7 @@ def multi_modal_agent(
working_directory=working_directory,
now_str=NOW_STR,
)
system_message = append_connected_app_mcp_notice(system_message)
system_message = attach_remote_sub_agent_if_enabled(
options=options,
agent_name=Agents.multi_modal_agent,

View file

@ -13,7 +13,10 @@
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
from app.agent.agent_model import agent_model
from app.agent.prompt import QUESTION_CONFIRM_SYS_PROMPT
from app.agent.prompt import (
QUESTION_CONFIRM_SYS_PROMPT,
append_connected_app_mcp_notice,
)
from app.agent.utils import NOW_STR
from app.model.chat import Chat
@ -21,6 +24,8 @@ from app.model.chat import Chat
def question_confirm_agent(options: Chat):
return agent_model(
"question_confirm_agent",
QUESTION_CONFIRM_SYS_PROMPT.format(now_str=NOW_STR),
append_connected_app_mcp_notice(
QUESTION_CONFIRM_SYS_PROMPT.format(now_str=NOW_STR)
),
options,
)

View file

@ -21,7 +21,10 @@ from camel.messages import BaseMessage
from app.agent.agent_model import agent_model
from app.agent.factory.toolkit_assembler import assemble_single_agent_toolkits
from app.agent.prompt import SINGLE_AGENT_SYS_PROMPT
from app.agent.prompt import (
SINGLE_AGENT_SYS_PROMPT,
append_connected_app_mcp_notice,
)
from app.agent.utils import NOW_STR
from app.hands.interface import IHands
from app.model.chat import Chat
@ -70,6 +73,7 @@ async def single_agent(
working_directory=working_directory,
now_str=NOW_STR,
)
system_message = append_connected_app_mcp_notice(system_message)
agent = agent_model(
Agents.single_agent,

View file

@ -19,7 +19,10 @@ from app.agent.factory.remote_sub_agent import (
attach_remote_sub_agent_if_enabled,
)
from app.agent.listen_chat_agent import logger
from app.agent.prompt import SOCIAL_MEDIA_SYS_PROMPT
from app.agent.prompt import (
SOCIAL_MEDIA_SYS_PROMPT,
append_connected_app_mcp_notice,
)
from app.agent.toolkit.google_calendar_toolkit import GoogleCalendarToolkit
from app.agent.toolkit.google_gmail_mcp_toolkit import GoogleGmailMCPToolkit
from app.agent.toolkit.human_toolkit import HumanToolkit
@ -110,6 +113,7 @@ async def social_media_agent(options: Chat):
system_message = SOCIAL_MEDIA_SYS_PROMPT.format(
working_directory=working_directory, now_str=NOW_STR
)
system_message = append_connected_app_mcp_notice(system_message)
system_message = attach_remote_sub_agent_if_enabled(
options=options,
agent_name=Agents.social_media_agent,

View file

@ -109,6 +109,29 @@ def build_remote_sub_agent_planning_notice() -> str:
return REMOTE_SUB_AGENT_PLANNING_NOTICE
CONNECTED_APP_MCP_NOTICE = """\
<connected_app_tools>
When the user asks to query or operate a third-party app that may already be
connected, use available MCP or connector tools before browser/manual-login
flows. Examples include Clerk, Slack, Gmail, Notion, GitHub, Google Calendar,
Google Drive, Jira, Linear, Lark, Airtable, HubSpot, and similar SaaS apps.
- Search or list the app's available actions first.
- Execute read/list/search/count actions directly when the user's intent is
clear and all required input is available or optional.
- Execute write/send/update/delete/admin actions only when the user explicitly
asked for that change and the target details are clear.
- Ask for dashboard links, API keys, or manual browser login only after the
connector tools show the app/account/action is unavailable or required input
is missing.
</connected_app_tools>
"""
def append_connected_app_mcp_notice(system_message: str) -> str:
return f"{system_message.rstrip()}\n\n{CONNECTED_APP_MCP_NOTICE}"
SOCIAL_MEDIA_SYS_PROMPT = """\
You are a Social Media Management Assistant with comprehensive capabilities
across multiple platforms. You MUST use the `send_message_to_user` tool to

View file

@ -49,7 +49,10 @@ from app.agent.factory.remote_sub_agent import (
remote_sub_agent_enabled,
)
from app.agent.listen_chat_agent import ListenChatAgent
from app.agent.prompt import build_remote_sub_agent_planning_notice
from app.agent.prompt import (
append_connected_app_mcp_notice,
build_remote_sub_agent_planning_notice,
)
from app.agent.toolkit.human_toolkit import HumanToolkit
from app.agent.toolkit.note_taking_toolkit import NoteTakingToolkit
from app.agent.toolkit.skill_toolkit import SkillToolkit
@ -2867,6 +2870,9 @@ The current date is {datetime.date.today()}. \
For any date-related tasks, you MUST use this as \
the current date.
"""
enhanced_description = append_connected_app_mcp_notice(
enhanced_description
)
message_integration = ToolkitMessageIntegration(
message_handler=HumanToolkit(
options.project_id, data.name

221
src/api/connectors.ts Normal file
View file

@ -0,0 +1,221 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import {
proxyFetchDelete,
proxyFetchGet,
proxyFetchPost,
proxyFetchPut,
} from '@/api/http';
export type ConnectorAuthType =
| 'no_auth'
| 'api_key'
| 'custom_credential'
| 'oauth2'
| string;
export interface ConnectorCredentialField {
key: string;
label: string;
inputType: 'text' | 'password' | 'textarea' | 'json' | string;
required: boolean;
secret: boolean;
placeholder?: string | null;
description?: string | null;
}
export interface ConnectorAuthDefinition {
type: ConnectorAuthType;
label?: string | null;
placeholder?: string | null;
description?: string | null;
extraFields?: ConnectorCredentialField[];
fields?: ConnectorCredentialField[];
scopes?: string[];
clientConfigFields?: ConnectorCredentialField[];
}
export interface ConnectorAction {
id?: string;
name?: string;
description?: string;
}
export interface ConnectorConnection {
id?: string | null;
service: string;
connectionName: string;
authType: ConnectorAuthType;
configured: boolean;
virtual: boolean;
default: boolean;
profile?: {
displayName?: string | null;
grantedScopes?: string[];
} | null;
}
export interface ConnectorProvider {
service: string;
displayName?: string;
description?: string | null;
iconUrl?: string | null;
homepageUrl?: string | null;
categories?: string[];
authTypes?: ConnectorAuthType[];
auth?: ConnectorAuthDefinition[];
action_count?: number;
locally_executable_action_count?: number;
catalog_only_action_count?: number;
recommended?: boolean;
sort_rank?: number | null;
connection?: ConnectorConnection | null;
actions?: ConnectorAction[];
}
export interface ConnectorProvidersResponse {
enabled: boolean;
source: 'connector_gateway';
provider_count: number;
filtered_count: number;
connected_count: number;
page: number;
page_size: number;
total_pages: number;
providers: ConnectorProvider[];
}
export interface ConnectorProviderResponse {
enabled: boolean;
source: 'connector_gateway';
provider: ConnectorProvider;
}
export interface ConnectProviderRequest {
auth_type: ConnectorAuthType;
values?: Record<string, unknown>;
connection_name?: string;
}
export interface ConnectorOAuthAuthorization {
service: string;
authorizationUrl: string;
state?: string;
}
export interface FetchConnectorProvidersOptions {
page?: number;
pageSize?: number;
query?: string;
}
export async function fetchConnectorProviders(
options: FetchConnectorProvidersOptions = {}
): Promise<ConnectorProvidersResponse> {
const params: Record<string, string | number> = {
page: options.page || 1,
page_size: options.pageSize || 24,
};
const query = options.query?.trim();
if (query) {
params.q = query;
}
const response = await proxyFetchGet('/api/v1/connectors/providers', params);
const providers = Array.isArray(response?.providers)
? response.providers
: [];
const providerCount =
typeof response?.provider_count === 'number'
? response.provider_count
: providers.length;
const filteredCount =
typeof response?.filtered_count === 'number'
? response.filtered_count
: providerCount;
const pageSize =
typeof response?.page_size === 'number'
? response.page_size
: options.pageSize || 24;
return {
enabled: response?.enabled === true,
source: 'connector_gateway',
provider_count: providerCount,
filtered_count: filteredCount,
connected_count:
typeof response?.connected_count === 'number'
? response.connected_count
: 0,
page:
typeof response?.page === 'number' ? response.page : options.page || 1,
page_size: pageSize,
total_pages:
typeof response?.total_pages === 'number'
? response.total_pages
: Math.max(1, Math.ceil(filteredCount / pageSize)),
providers,
};
}
export async function fetchConnectorProvider(
service: string
): Promise<ConnectorProviderResponse> {
const response = await proxyFetchGet(
`/api/v1/connectors/providers/${encodeURIComponent(service)}`
);
return {
enabled: response?.enabled === true,
source: 'connector_gateway',
provider: response?.provider,
};
}
export async function connectProvider(
service: string,
request: ConnectProviderRequest
) {
return proxyFetchPut(
`/api/v1/connectors/connections/${encodeURIComponent(service)}`,
request
);
}
export async function disconnectProvider(
service: string,
connectionName?: string
) {
return proxyFetchDelete(
`/api/v1/connectors/connections/${encodeURIComponent(service)}`,
connectionName ? { connection_name: connectionName } : undefined
);
}
export async function createConnectorOAuthAuthorization(
service: string,
connectionName?: string
): Promise<ConnectorOAuthAuthorization> {
const response = await proxyFetchPost(
'/api/v1/connectors/oauth/authorizations',
{
service,
connection_name: connectionName,
}
);
const authorization = response?.authorization;
return {
service: authorization?.service || service,
authorizationUrl: authorization?.authorizationUrl || '',
state: authorization?.state,
};
}

View file

@ -0,0 +1,904 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import {
connectProvider,
createConnectorOAuthAuthorization,
disconnectProvider,
fetchConnectorProvider,
fetchConnectorProviders,
type ConnectorAction,
type ConnectorAuthDefinition,
type ConnectorCredentialField,
type ConnectorProvider,
} from '@/api/connectors';
import SearchInput from '@/components/Dashboard/SearchInput';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { Textarea } from '@/components/ui/textarea';
import { TooltipSimple } from '@/components/ui/tooltip';
import { useServerCapabilityStore } from '@/store/serverCapabilityStore';
import {
CheckCircle2,
ChevronLeft,
ChevronRight,
ExternalLink,
KeyRound,
ListChecks,
Loader2,
PlugZap,
RefreshCw,
ShieldCheck,
Sparkles,
Trash2,
} from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
const CONNECTOR_PAGE_SIZE = 24;
function providerLabel(provider: ConnectorProvider): string {
return provider.displayName || provider.service;
}
function providerActionCount(provider: ConnectorProvider): number {
return typeof provider.action_count === 'number'
? provider.action_count
: Array.isArray(provider.actions)
? provider.actions.length
: 0;
}
function providerActionCountOrZero(
provider: ConnectorProvider | null | undefined
): number {
return provider ? providerActionCount(provider) : 0;
}
function actionLabel(action: ConnectorAction): string {
return action.name || action.id || 'Unnamed action';
}
function isConnected(provider: ConnectorProvider | null | undefined): boolean {
const connection = provider?.connection;
return (
connection?.configured === true &&
connection.virtual !== true &&
connection.authType !== 'no_auth'
);
}
function authLabel(authType: string): string {
if (authType === 'api_key') return 'API key';
if (authType === 'custom_credential') return 'Credential';
if (authType === 'oauth2') return 'OAuth';
if (authType === 'no_auth') return 'No auth';
return authType;
}
function authPriority(authType: string): number {
if (authType === 'api_key') return 0;
if (authType === 'custom_credential') return 1;
if (authType === 'no_auth') return 2;
if (authType === 'oauth2') return 3;
return 10;
}
function credentialFieldsFor(
auth: ConnectorAuthDefinition | undefined
): ConnectorCredentialField[] {
if (!auth) return [];
if (auth.type === 'api_key') {
return [
{
key: 'apiKey',
label: auth.label || 'API key',
inputType: 'password',
required: true,
secret: true,
placeholder: auth.placeholder,
description: auth.description,
},
...(auth.extraFields || []),
];
}
if (auth.type === 'custom_credential') {
return auth.fields || [];
}
return [];
}
function authDefinitions(
provider: ConnectorProvider | null
): ConnectorAuthDefinition[] {
if (!provider) return [];
if (provider.auth?.length) {
return [...provider.auth].sort(
(left, right) => authPriority(left.type) - authPriority(right.type)
);
}
return (provider.authTypes || []).map((type) => ({ type }));
}
function preferredAuthType(provider: ConnectorProvider | null): string | null {
const definitions = authDefinitions(provider);
if (!definitions.length) return null;
const connectedAuth = provider?.connection?.authType;
if (
connectedAuth &&
definitions.some((auth) => auth.type === connectedAuth)
) {
return connectedAuth;
}
return definitions[0].type;
}
function updateProviderInList(
providers: ConnectorProvider[],
updated: ConnectorProvider
): ConnectorProvider[] {
return providers.map((provider) =>
provider.service === updated.service
? { ...provider, ...updated }
: provider
);
}
function ProviderIcon({
provider,
size = 'sm',
}: {
provider: ConnectorProvider | null | undefined;
size?: 'sm' | 'lg';
}) {
const iconUrl = provider?.iconUrl || '';
const [iconFailed, setIconFailed] = useState(false);
useEffect(() => {
setIconFailed(false);
}, [iconUrl]);
const shellClass =
size === 'lg' ? 'h-12 w-12 rounded-xl' : 'h-11 w-11 rounded-xl';
const imageClass = size === 'lg' ? 'h-7 w-7' : 'h-7 w-7';
const fallbackClass = size === 'lg' ? 'h-6 w-6' : 'h-5 w-5';
return (
<div
className={`flex shrink-0 items-center justify-center border border-solid border-ds-border-neutral-default-default bg-ds-bg-neutral-default-default ${shellClass}`}
>
{iconUrl && !iconFailed ? (
<img
src={iconUrl}
alt=""
className={`${imageClass} object-contain`}
onError={() => setIconFailed(true)}
/>
) : (
<PlugZap
className={`${fallbackClass} text-ds-icon-neutral-muted-default`}
/>
)}
</div>
);
}
export default function ConnectorGateway() {
const { t } = useTranslation();
const capabilities = useServerCapabilityStore((state) => state.capabilities);
const capabilityStatus = useServerCapabilityStore((state) => state.status);
const fetchCapabilities = useServerCapabilityStore(
(state) => state.fetchCapabilities
);
const [providers, setProviders] = useState<ConnectorProvider[]>([]);
const [providerCount, setProviderCount] = useState(0);
const [filteredCount, setFilteredCount] = useState(0);
const [connectedCount, setConnectedCount] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(CONNECTOR_PAGE_SIZE);
const [totalPages, setTotalPages] = useState(1);
const [loadingProviders, setLoadingProviders] = useState(false);
const [providerError, setProviderError] = useState<string | null>(null);
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const [selectedProvider, setSelectedProvider] =
useState<ConnectorProvider | null>(null);
const [selectedDetail, setSelectedDetail] =
useState<ConnectorProvider | null>(null);
const [loadingDetail, setLoadingDetail] = useState(false);
const [selectedAuthType, setSelectedAuthType] = useState<string | null>(null);
const [values, setValues] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const connectorGatewayEnabled =
capabilities.features.connector_gateway.enabled === true;
useEffect(() => {
void fetchCapabilities();
}, [fetchCapabilities]);
useEffect(() => {
const timer = window.setTimeout(() => {
setDebouncedQuery(query.trim());
}, 250);
return () => window.clearTimeout(timer);
}, [query]);
const refreshProviders = useCallback(
async (targetPage: number = page) => {
if (!connectorGatewayEnabled) return;
setLoadingProviders(true);
setProviderError(null);
try {
const response = await fetchConnectorProviders({
page: targetPage,
pageSize,
query: debouncedQuery,
});
setProviders(response.providers);
setProviderCount(response.provider_count);
setFilteredCount(response.filtered_count);
setConnectedCount(response.connected_count);
setPageSize(response.page_size);
setTotalPages(response.total_pages);
if (response.page !== targetPage) {
setPage(response.page);
}
} catch (error: any) {
setProviderError(
error?.message ||
t('setting.connector-gateway-load-failed', {
defaultValue: 'Failed to load Connector Gateway providers',
})
);
setProviders([]);
setProviderCount(0);
setFilteredCount(0);
setConnectedCount(0);
setTotalPages(1);
} finally {
setLoadingProviders(false);
}
},
[connectorGatewayEnabled, debouncedQuery, page, pageSize, t]
);
useEffect(() => {
if (connectorGatewayEnabled) {
void refreshProviders(page);
}
}, [connectorGatewayEnabled, debouncedQuery, page, refreshProviders]);
const selected = selectedDetail || selectedProvider;
const selectedAuth = useMemo(
() =>
authDefinitions(selected).find((auth) => auth.type === selectedAuthType),
[selected, selectedAuthType]
);
const selectedFields = useMemo(
() => credentialFieldsFor(selectedAuth),
[selectedAuth]
);
const selectedConnected = isConnected(selected);
const authOptions = authDefinitions(selected);
const selectedActions = selected?.actions || [];
const canSave =
Boolean(selected && selectedAuth) &&
(selectedAuth?.type === 'oauth2' ||
selectedAuth?.type === 'no_auth' ||
selectedFields.every((field) => !field.required || values[field.key]));
const pageStart = filteredCount === 0 ? 0 : (page - 1) * pageSize + 1;
const pageEnd = Math.min(page * pageSize, filteredCount);
const showPagination = filteredCount > pageSize;
const canGoPrevious = page > 1 && !loadingProviders;
const canGoNext = page < totalPages && !loadingProviders;
const initialProvidersLoading =
(capabilityStatus === 'loading' || loadingProviders) &&
providers.length === 0;
const listRefreshing = loadingProviders && providers.length > 0;
const pagedGridMinHeightClass = showPagination
? 'min-h-[1872px] md:min-h-[936px] xl:min-h-[624px]'
: '';
const skeletonItems = Array.from({ length: pageSize || CONNECTOR_PAGE_SIZE });
const openProvider = useCallback((provider: ConnectorProvider) => {
setSelectedProvider(provider);
setSelectedDetail(null);
setSelectedAuthType(preferredAuthType(provider));
setValues({});
setFormError(null);
}, []);
const closeProvider = useCallback(() => {
setSelectedProvider(null);
setSelectedDetail(null);
setSelectedAuthType(null);
setValues({});
setFormError(null);
}, []);
useEffect(() => {
if (!selectedProvider) return;
let cancelled = false;
setLoadingDetail(true);
void fetchConnectorProvider(selectedProvider.service)
.then((response) => {
if (cancelled) return;
setSelectedDetail(response.provider);
setSelectedAuthType(preferredAuthType(response.provider));
setProviders((current) =>
updateProviderInList(current, response.provider)
);
})
.catch((error: any) => {
if (cancelled) return;
setFormError(error?.message || 'Failed to load connector details');
})
.finally(() => {
if (!cancelled) setLoadingDetail(false);
});
return () => {
cancelled = true;
};
}, [selectedProvider]);
const refreshSelectedProvider = useCallback(async () => {
if (!selected) return;
const response = await fetchConnectorProvider(selected.service);
setSelectedDetail(response.provider);
setProviders((current) => updateProviderInList(current, response.provider));
}, [selected]);
const saveConnection = useCallback(async () => {
if (!selected || !selectedAuth) return;
if (selectedAuth.type === 'oauth2') {
setSaving(true);
setFormError(null);
try {
const authorization = await createConnectorOAuthAuthorization(
selected.service,
selected.connection?.connectionName
);
if (!authorization.authorizationUrl) {
throw new Error(
'Connector Gateway did not return an authorization URL'
);
}
window.open(
authorization.authorizationUrl,
'eigent_connector_oauth',
'popup=yes,width=720,height=760,menubar=no,toolbar=no,location=yes,status=no'
);
toast.success(
t('setting.connector-gateway-oauth-started', {
defaultValue: 'OAuth authorization started',
})
);
} catch (error: any) {
setFormError(error?.message || 'Failed to start OAuth authorization');
} finally {
setSaving(false);
}
return;
}
setSaving(true);
setFormError(null);
try {
await connectProvider(selected.service, {
auth_type: selectedAuth.type,
values:
selectedAuth.type === 'no_auth'
? {}
: selectedFields.reduce<Record<string, string>>((acc, field) => {
acc[field.key] = values[field.key] || '';
return acc;
}, {}),
});
await refreshSelectedProvider();
setPage(1);
await refreshProviders(1);
toast.success(
t('setting.connector-gateway-saved', {
defaultValue: 'Connector saved',
})
);
} catch (error: any) {
setFormError(error?.message || 'Failed to save connector');
} finally {
setSaving(false);
}
}, [
refreshProviders,
refreshSelectedProvider,
selected,
selectedAuth,
selectedFields,
t,
values,
]);
const removeConnection = useCallback(async () => {
if (!selected?.connection) return;
setSaving(true);
setFormError(null);
try {
await disconnectProvider(
selected.service,
selected.connection.connectionName
);
await refreshSelectedProvider();
setPage(1);
await refreshProviders(1);
toast.success(
t('setting.connector-gateway-disconnected', {
defaultValue: 'Connector disconnected',
})
);
} catch (error: any) {
setFormError(error?.message || 'Failed to disconnect connector');
} finally {
setSaving(false);
}
}, [refreshProviders, refreshSelectedProvider, selected, t]);
if (!connectorGatewayEnabled) {
return null;
}
return (
<>
<div className="mx-auto flex w-full flex-col px-6 pt-6">
<div className="rounded-2xl bg-ds-bg-neutral-default-default px-3 py-2">
<div className="flex flex-wrap items-center justify-between gap-3 border-x-0 border-b-[0.5px] border-t-0 border-solid border-ds-border-neutral-default-default px-3 py-3">
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-ds-bg-neutral-subtle-default">
<PlugZap className="h-5 w-5 text-ds-icon-neutral-default-default" />
</div>
<div className="min-w-0">
<div className="text-body-base truncate font-bold text-ds-text-neutral-default-default">
Connector Gateway
</div>
<div className="text-body-xs text-ds-text-neutral-muted-default">
{providerCount || providers.length} connectors
{connectedCount > 0 ? ` | ${connectedCount} connected` : ''}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<SearchInput
variant="icon"
value={query}
onChange={(event) => {
setQuery(event.target.value);
setPage(1);
}}
placeholder={t('setting.search-mcp')}
/>
<TooltipSimple content={t('setting.refresh')}>
<Button
type="button"
variant="ghost"
size="sm"
buttonContent="icon-only"
disabled={loadingProviders}
aria-label={t('setting.refresh')}
onClick={() => void refreshProviders(page)}
>
{loadingProviders ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</Button>
</TooltipSimple>
</div>
</div>
{initialProvidersLoading ? (
<div className="mx-3 my-4 grid grid-cols-1 overflow-hidden rounded-xl border border-solid border-ds-border-neutral-default-default md:grid-cols-2 xl:grid-cols-3">
{skeletonItems.map((_, index) => (
<div
key={index}
className="flex min-h-[78px] items-center gap-3 border-x-0 border-b border-r border-t-0 border-solid border-ds-border-neutral-default-default bg-ds-bg-neutral-subtle-default px-4 py-3"
>
<div className="h-11 w-11 shrink-0 rounded-xl bg-ds-bg-neutral-strong-default" />
<div className="min-w-0 flex-1 space-y-2">
<div className="h-3.5 w-32 rounded bg-ds-bg-neutral-strong-default" />
<div className="h-2.5 w-20 rounded bg-ds-bg-neutral-strong-default" />
</div>
<div className="h-3 w-14 rounded bg-ds-bg-neutral-strong-default" />
</div>
))}
</div>
) : providerError ? (
<div className="px-3 py-4 text-body-sm text-ds-text-error-default-default">
{providerError}
</div>
) : providers.length === 0 ? (
<div className="px-3 py-4 text-body-sm text-ds-text-neutral-muted-default">
{t('setting.no-connectors-found', {
defaultValue: 'No connectors found',
})}
</div>
) : (
<>
<div className="relative mx-3 my-4">
<div
className={`grid grid-cols-1 overflow-hidden rounded-xl border border-solid border-ds-border-neutral-default-default transition-opacity md:grid-cols-2 xl:grid-cols-3 ${
listRefreshing ? 'opacity-60' : 'opacity-100'
} ${pagedGridMinHeightClass}`}
>
{providers.map((provider) => {
const connected = isConnected(provider);
return (
<button
key={provider.service}
type="button"
onClick={() => openProvider(provider)}
disabled={loadingProviders}
className="group flex min-h-[78px] w-full items-center gap-3 border-x-0 border-b border-r border-t-0 border-solid border-ds-border-neutral-default-default bg-ds-bg-neutral-subtle-default px-4 py-3 text-left transition-colors hover:bg-ds-bg-neutral-default-default focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ds-ring-brand-default-focus"
>
<ProviderIcon provider={provider} />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-body-sm font-bold text-ds-text-neutral-default-default">
{providerLabel(provider)}
</span>
{connected ? (
<span className="h-2 w-2 shrink-0 rounded-full bg-ds-text-success-default-default" />
) : provider.recommended ? (
<Sparkles className="h-3.5 w-3.5 shrink-0 text-ds-icon-brand-default-default" />
) : null}
</div>
</div>
<span className="shrink-0 text-label-sm font-bold text-ds-text-neutral-muted-default">
{connected
? t('setting.connected', {
defaultValue: 'Connected',
})
: t('setting.connect', {
defaultValue: 'Connect',
})}
</span>
<ChevronRight className="h-4 w-4 shrink-0 text-ds-icon-neutral-muted-default transition-transform group-hover:translate-x-0.5" />
</button>
);
})}
</div>
{listRefreshing ? (
<div className="pointer-events-none absolute inset-0 flex items-start justify-end p-3">
<div className="flex items-center gap-2 rounded-lg border border-solid border-ds-border-neutral-default-default bg-ds-bg-neutral-default-default px-3 py-2 text-body-xs font-bold text-ds-text-neutral-muted-default shadow-sm">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t('setting.loading')}
</div>
</div>
) : null}
</div>
{showPagination ? (
<div className="flex flex-wrap items-center justify-between gap-3 border-x-0 border-b-0 border-t-[0.5px] border-solid border-ds-border-neutral-default-default px-3 py-3">
<div className="text-body-xs text-ds-text-neutral-muted-default">
Showing {pageStart}-{pageEnd} of {filteredCount}
{debouncedQuery ? ' results' : ' connectors'}
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="secondary"
size="sm"
disabled={!canGoPrevious}
onClick={() =>
setPage((current) => Math.max(1, current - 1))
}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<div className="min-w-[88px] text-center text-body-xs text-ds-text-neutral-muted-default">
{page} / {totalPages}
</div>
<Button
type="button"
variant="secondary"
size="sm"
disabled={!canGoNext}
onClick={() =>
setPage((current) => Math.min(totalPages, current + 1))
}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
) : null}
</>
)}
</div>
</div>
<Sheet
open={Boolean(selectedProvider)}
onOpenChange={(open) => {
if (!open) closeProvider();
}}
>
<SheetContent className="flex !w-[520px] !max-w-[calc(100vw-24px)] flex-col gap-0 p-0 sm:!max-w-[520px]">
<SheetHeader className="border-x-0 border-b border-t-0 border-solid border-ds-border-neutral-default-default p-6 pr-12 text-left">
<div className="flex items-start gap-3">
<ProviderIcon provider={selected} size="lg" />
<div className="min-w-0 flex-1">
<SheetTitle className="text-heading-xs truncate">
{selected ? providerLabel(selected) : 'Connector'}
</SheetTitle>
<SheetDescription className="mt-1 line-clamp-2">
{selected?.description ||
`${providerActionCountOrZero(selected)} actions`}
</SheetDescription>
</div>
</div>
</SheetHeader>
<div className="flex-1 space-y-5 overflow-y-auto p-6">
{loadingDetail ? (
<div className="space-y-3">
<div className="h-10 rounded-xl bg-ds-bg-neutral-strong-default" />
<div className="h-28 rounded-xl bg-ds-bg-neutral-strong-default" />
<div className="h-10 rounded-xl bg-ds-bg-neutral-strong-default" />
</div>
) : selected ? (
<>
<div className="flex flex-wrap items-center gap-2">
{selectedConnected ? (
<Badge
size="sm"
variant="secondary"
tone="success"
emphasis="subtle"
>
<CheckCircle2 className="h-3.5 w-3.5" />
Connected
</Badge>
) : null}
{selected.recommended ? (
<Badge size="sm" variant="secondary" emphasis="subtle">
<Sparkles className="h-3.5 w-3.5" />
Popular
</Badge>
) : null}
<Badge size="sm" variant="outline">
{providerActionCount(selected)} actions
</Badge>
{selected.homepageUrl ? (
<a
href={selected.homepageUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-label-sm text-ds-text-neutral-muted-default underline-offset-2 hover:underline"
>
Website
<ExternalLink className="h-3.5 w-3.5" />
</a>
) : null}
</div>
{selectedActions.length > 0 ? (
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2 text-body-sm font-bold text-ds-text-neutral-default-default">
<ListChecks className="h-4 w-4 shrink-0 text-ds-icon-neutral-default-default" />
<span>Actions</span>
</div>
<span className="shrink-0 text-body-xs text-ds-text-neutral-muted-default">
{selectedActions.length}
</span>
</div>
<div className="max-h-[280px] overflow-y-auto rounded-xl border border-solid border-ds-border-neutral-default-default bg-ds-bg-neutral-subtle-default">
{selectedActions.map((action, index) => (
<div
key={action.id || action.name || index}
className={`px-4 py-3 ${
index < selectedActions.length - 1
? 'border-x-0 border-b border-t-0 border-solid border-ds-border-neutral-default-default'
: ''
}`}
>
<div className="min-w-0 text-body-sm font-bold text-ds-text-neutral-default-default">
{actionLabel(action)}
</div>
{action.id && action.id !== action.name ? (
<div className="mt-0.5 truncate text-body-xs text-ds-text-neutral-muted-default">
{action.id}
</div>
) : null}
{action.description ? (
<div className="mt-1 text-body-xs leading-5 text-ds-text-neutral-muted-default">
{action.description}
</div>
) : null}
</div>
))}
</div>
</div>
) : null}
{authOptions.length > 1 ? (
<div className="space-y-2">
<div className="text-body-sm font-bold text-ds-text-neutral-default-default">
Authentication
</div>
<div className="flex flex-wrap gap-2">
{authOptions.map((auth) => (
<button
key={auth.type}
type="button"
onClick={() => {
setSelectedAuthType(auth.type);
setValues({});
setFormError(null);
}}
className={`rounded-lg border border-solid px-3 py-1.5 text-label-sm transition-colors ${
selectedAuthType === auth.type
? 'border-ds-border-brand-default-default bg-ds-bg-brand-default-default text-ds-text-brand-inverse-default'
: 'border-ds-border-neutral-default-default bg-ds-bg-neutral-default-default text-ds-text-neutral-default-default hover:bg-ds-bg-neutral-subtle-default'
}`}
>
{authLabel(auth.type)}
</button>
))}
</div>
</div>
) : null}
{selectedAuth?.type === 'oauth2' ? (
<div className="rounded-xl border border-solid border-ds-border-neutral-default-default bg-ds-bg-neutral-subtle-default p-4 text-body-sm text-ds-text-neutral-muted-default">
<div className="mb-2 font-bold text-ds-text-neutral-default-default">
Connect with OAuth
</div>
Eigent will open the provider authorization page through
Connector Gateway. After authorization completes, refresh
this connector to see the connected account.
{selectedAuth.scopes?.length ? (
<div className="mt-3 line-clamp-3 text-body-xs">
Scopes: {selectedAuth.scopes.join(', ')}
</div>
) : null}
</div>
) : selectedAuth?.type === 'no_auth' ? (
<div className="flex items-start gap-3 rounded-xl border border-solid border-ds-border-neutral-default-default bg-ds-bg-neutral-subtle-default p-4 text-body-sm text-ds-text-neutral-muted-default">
<ShieldCheck className="mt-0.5 h-4 w-4 shrink-0 text-ds-icon-neutral-default-default" />
This connector does not require credentials.
</div>
) : selectedFields.length > 0 ? (
<div className="space-y-3">
{selectedFields.map((field) =>
field.inputType === 'textarea' ||
field.inputType === 'json' ? (
<Textarea
key={field.key}
variant="enhanced"
title={field.label}
required={field.required}
placeholder={field.placeholder || undefined}
note={field.description || undefined}
value={values[field.key] || ''}
onChange={(event) =>
setValues((current) => ({
...current,
[field.key]: event.target.value,
}))
}
className="min-h-[92px]"
/>
) : (
<Input
key={field.key}
title={field.label}
required={field.required}
type={field.secret ? 'password' : 'text'}
placeholder={field.placeholder || undefined}
note={field.description || undefined}
leadingIcon={<KeyRound className="h-4 w-4" />}
value={values[field.key] || ''}
onChange={(event) =>
setValues((current) => ({
...current,
[field.key]: event.target.value,
}))
}
/>
)
)}
</div>
) : (
<div className="rounded-xl border border-solid border-ds-border-neutral-default-default bg-ds-bg-neutral-subtle-default p-4 text-body-sm text-ds-text-neutral-muted-default">
No credential fields are declared for this authentication
type.
</div>
)}
{formError ? (
<div className="rounded-xl bg-ds-bg-error-subtle-default p-3 text-body-sm text-ds-text-error-strong-default">
{formError}
</div>
) : null}
</>
) : null}
</div>
<SheetFooter className="border-x-0 border-b-0 border-t border-solid border-ds-border-neutral-default-default p-4">
<div className="flex w-full items-center justify-between gap-3">
{selectedConnected ? (
<Button
type="button"
variant="ghost"
tone="error"
disabled={saving}
onClick={() => void removeConnection()}
>
<Trash2 className="h-4 w-4" />
Disconnect
</Button>
) : (
<div />
)}
<div className="flex items-center gap-2">
<Button
type="button"
variant="secondary"
disabled={saving}
onClick={closeProvider}
>
Cancel
</Button>
<Button
type="button"
variant="primary"
disabled={!canSave || saving}
onClick={() => void saveConnection()}
>
{saving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : selectedAuth?.type === 'oauth2' ? (
<ExternalLink className="h-4 w-4" />
) : (
<CheckCircle2 className="h-4 w-4" />
)}
{selectedAuth?.type === 'oauth2'
? selectedConnected
? 'Reconnect OAuth'
: 'Connect OAuth'
: selectedConnected
? 'Save'
: 'Connect'}
</Button>
</div>
</div>
</SheetFooter>
</SheetContent>
</Sheet>
</>
);
}

View file

@ -12,11 +12,13 @@
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import ConnectorGateway from './ConnectorGateway';
import MCP from './MCP';
export default function Connectors() {
return (
<div className="flex h-auto w-full flex-1 flex-col">
<ConnectorGateway />
<MCP />
</div>
);

View file

@ -57,9 +57,11 @@ import { getAuthStore, getWorkerList } from './authStore';
import { getCloudModelStore } from './cloudModelStore';
import { usePageTabStore } from './pageTabStore';
import { useProjectStore } from './projectStore';
import { getServerCapabilityStore } from './serverCapabilityStore';
import { legacySpaceIdForUser, useSpaceStore } from './spaceStore';
const API_CODE_TRIAL_LIMIT = '22';
const CONNECTOR_GATEWAY_MCP_NAME = 'connector_gateway';
const PROJECT_CONTEXT_MAX_CHARS = 24_000;
const PROJECT_CONTEXT_MAX_RUNS = 8;
// chat_history.summary is a bounded database column; an over-long value
@ -196,6 +198,84 @@ function getDirectServerApiBaseUrl(): string | undefined {
return normalizeServerApiBaseUrl(import.meta.env.VITE_BASE_URL);
}
function hasMcpServers(config: any): boolean {
return Boolean(
config &&
typeof config === 'object' &&
config.mcpServers &&
typeof config.mcpServers === 'object' &&
Object.keys(config.mcpServers).length > 0
);
}
function mergeMcpConfigs(...configs: any[]): {
mcpServers: Record<string, any>;
} {
const mcpServers: Record<string, any> = {};
configs.forEach((config) => {
if (!hasMcpServers(config)) {
return;
}
Object.assign(mcpServers, config.mcpServers);
});
return { mcpServers };
}
async function buildConnectorGatewayMcpConfig(
token?: string | null
): Promise<{ mcpServers: Record<string, any> } | null> {
if (!token) {
return null;
}
try {
let connectorGatewayEnabled = false;
if (import.meta.env.VITE_USE_LOCAL_PROXY === 'true') {
// The Connectors page can stay visible in local debug mode, but chat
// should only mount the MCP server when eigent_server confirms it is
// actually configured. Otherwise CAMEL tries to connect to a disabled
// endpoint and connector tools silently disappear.
const capabilities = await proxyFetchGet('/api/v1/server/capabilities');
connectorGatewayEnabled =
capabilities?.features?.connector_gateway?.enabled === true;
} else {
const capabilities =
await getServerCapabilityStore().fetchCapabilities(false);
connectorGatewayEnabled =
capabilities.features.connector_gateway.enabled === true;
}
if (!connectorGatewayEnabled) {
return null;
}
} catch (error) {
console.warn(
'Failed to resolve Connector Gateway capability for MCP:',
error
);
return null;
}
const serverApiBaseUrl = getDirectServerApiBaseUrl();
if (!serverApiBaseUrl) {
return null;
}
return {
mcpServers: {
[CONNECTOR_GATEWAY_MCP_NAME]: {
type: 'streamable_http',
url: `${serverApiBaseUrl}/connectors/mcp`,
headers: {
Authorization: `Bearer ${token}`,
},
timeout: 180,
},
},
};
}
function getHostElectronAPI() {
return _host?.electronAPI ?? null;
}
@ -1831,6 +1911,10 @@ const chatStore = (initial?: Partial<ChatStore>) =>
}
}
const connectorGatewayMcpConfig = !type
? await buildConnectorGatewayMcpConfig(token)
: null;
const addWorkers = workerList.map((worker) => {
const providerId = worker.workerInfo?.model_provider_id;
const provider = Number.isInteger(providerId)
@ -1840,7 +1924,10 @@ const chatStore = (initial?: Partial<ChatStore>) =>
name: worker.workerInfo?.name,
description: worker.workerInfo?.description,
tools: worker.workerInfo?.tools,
mcp_tools: worker.workerInfo?.mcp_tools,
mcp_tools: mergeMcpConfigs(
worker.workerInfo?.mcp_tools,
connectorGatewayMcpConfig
),
custom_model_config: provider
? buildAgentModelConfigFromProvider(provider)
: undefined,
@ -2000,7 +2087,7 @@ const chatStore = (initial?: Partial<ChatStore>) =>
model_config_dict: apiModel.model_config_dict,
extra_params: apiModel.extra_params,
auth_source: apiModel.auth_source,
installed_mcp: { mcpServers: {} },
installed_mcp: connectorGatewayMcpConfig || { mcpServers: {} },
language: systemLanguage,
allow_local_system: true,
attaches: (

View file

@ -0,0 +1,153 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import { proxyFetchGet } from '@/api/http';
import { create } from 'zustand';
const CAPABILITY_CACHE_TTL_MS = 60 * 1000;
let capabilityRefreshPromise: Promise<ServerCapabilities> | null = null;
export interface FeatureCapability {
enabled: boolean;
provider?: string | null;
reason?: string | null;
}
export interface ServerCapabilities {
features: {
connector_gateway: FeatureCapability;
};
}
type CapabilityStatus = 'idle' | 'loading' | 'ready' | 'error';
interface ServerCapabilityState {
capabilities: ServerCapabilities;
status: CapabilityStatus;
lastFetchedAt: number;
error: string | null;
fetchCapabilities: (force?: boolean) => Promise<ServerCapabilities>;
isConnectorGatewayEnabled: () => boolean;
}
const disabledCapabilities = (reason: string): ServerCapabilities => ({
features: {
connector_gateway: {
enabled: false,
provider: null,
reason,
},
},
});
function normalizeCapabilities(raw: unknown): ServerCapabilities {
const input = raw as Partial<ServerCapabilities> | undefined;
const connectorGateway = input?.features?.connector_gateway;
return {
features: {
connector_gateway: {
enabled: connectorGateway?.enabled === true,
provider:
typeof connectorGateway?.provider === 'string'
? connectorGateway.provider
: null,
reason:
typeof connectorGateway?.reason === 'string'
? connectorGateway.reason
: null,
},
},
};
}
export const useServerCapabilityStore = create<ServerCapabilityState>()(
(set, get) => ({
capabilities: disabledCapabilities('not_loaded'),
status: 'idle',
lastFetchedAt: 0,
error: null,
fetchCapabilities: async (force = false) => {
if (import.meta.env.VITE_USE_LOCAL_PROXY === 'true') {
// Local debug: keep Connector Gateway visible while using local auto-login.
// Before release, switch this branch back to disabledCapabilities('local_proxy')
// so fully-local self-hosted users do not request Eigent server connectors.
const localCapabilities: ServerCapabilities = {
features: {
connector_gateway: {
enabled: true,
provider: 'connector_gateway',
reason: 'local_debug',
},
},
};
set({
capabilities: localCapabilities,
status: 'ready',
lastFetchedAt: Date.now(),
error: null,
});
return localCapabilities;
}
const state = get();
const now = Date.now();
if (
!force &&
state.status === 'ready' &&
now - state.lastFetchedAt < CAPABILITY_CACHE_TTL_MS
) {
return state.capabilities;
}
if (capabilityRefreshPromise) {
return capabilityRefreshPromise;
}
capabilityRefreshPromise = (async () => {
set({ status: 'loading', error: null });
try {
const capabilities = normalizeCapabilities(
await proxyFetchGet('/api/v1/server/capabilities')
);
set({
capabilities,
status: 'ready',
lastFetchedAt: Date.now(),
error: null,
});
return capabilities;
} catch (error: any) {
const fallback = disabledCapabilities('capability_request_failed');
set({
capabilities: fallback,
status: 'error',
lastFetchedAt: Date.now(),
error: error?.message || 'Failed to load server capabilities',
});
return fallback;
} finally {
capabilityRefreshPromise = null;
}
})();
return capabilityRefreshPromise;
},
isConnectorGatewayEnabled: () =>
get().capabilities.features.connector_gateway.enabled === true,
})
);
export const getServerCapabilityStore = () =>
useServerCapabilityStore.getState();