diff --git a/docs/content/Guides/Integrations/mcp-tool-integration.mdx b/docs/content/Guides/Integrations/mcp-tool-integration.mdx index 4d89fafe..083bc1a3 100644 --- a/docs/content/Guides/Integrations/mcp-tool-integration.mdx +++ b/docs/content/Guides/Integrations/mcp-tool-integration.mdx @@ -26,7 +26,7 @@ If not set, it is derived from the host of `CONNECTOR_REDIRECT_BASE_URI`, then f ### Step 2: Add an MCP Server -Go to **Settings** > **Connectors** and pick a preset (Notion, Linear, Atlassian, Sentry, Asana, Stripe), or choose **Add custom connector** > **MCP server**. The same form opens from **Settings** > **Tools** > **Add Tool**. Enter the server URL, select an auth type, and click **Test Connection** to verify, then **Save**. +Go to **Settings** > **Connectors** and pick a preset (Notion, Linear, Atlassian, Sentry, Asana, Stripe): **Sign in to Notion** opens the service's sign-in, and its tools are ready when you come back. For any other server, choose **Add custom connector** > **MCP server** and enter its URL and authentication; scopes and the timeout are under **Show advanced**. Enter the server URL, select an auth type, and click **Test Connection** to verify, then **Save**. ### Step 3: Enable for Your Agent @@ -34,7 +34,7 @@ In your agent configuration, enable the MCP tools you want the agent to use. -Presets fill in the server URL and sign in with OAuth. Admins can turn presets off one by one, and turn off custom MCP servers, in **Admin > Connectors**. See [Connectors](/Guides/Connectors). +Presets need no URL or form: they sign in with OAuth in one step. Admins can turn presets off one by one, and turn off custom MCP servers, in **Admin > Connectors**. See [Connectors](/Guides/Connectors). ## Authentication Types diff --git a/docsgpt/api/user/tools/mcp.py b/docsgpt/api/user/tools/mcp.py index 8fce355a..318d1b91 100644 --- a/docsgpt/api/user/tools/mcp.py +++ b/docsgpt/api/user/tools/mcp.py @@ -310,8 +310,14 @@ class MCPServerSave(Resource): mcp_config = config.copy() mcp_config["auth_credentials"] = auth_credentials - if auth_type == "oauth": - if not config.get("oauth_task_id"): + if auth_type == "oauth" and not config.get("oauth_task_id"): + # No new handshake: the caller signed in to this server + # before, so its stored tokens answer the discovery. + try: + mcp_tool = MCPTool(config=mcp_config, user_id=user) + mcp_tool.discover_tools() + actions_metadata = mcp_tool.get_actions_metadata() + except Exception: return make_response( jsonify( { @@ -321,6 +327,7 @@ class MCPServerSave(Resource): ), 400, ) + elif auth_type == "oauth": redis_client = get_redis_instance() manager = MCPOAuthManager(redis_client) result = manager.get_oauth_status( diff --git a/frontend/src/connectors/ConnectWizard.test.tsx b/frontend/src/connectors/ConnectWizard.test.tsx index 18309962..472163e4 100644 --- a/frontend/src/connectors/ConnectWizard.test.tsx +++ b/frontend/src/connectors/ConnectWizard.test.tsx @@ -23,6 +23,15 @@ const service = vi.hoisted(() => ({ })); vi.mock('../api/services/connectorsService', () => ({ default: service })); +const mcpApi = vi.hoisted(() => ({ + testMCPConnection: vi.fn(), + saveMCPServer: vi.fn(), +})); +vi.mock('../api/services/userService', async (importOriginal) => { + const original = await importOriginal<{ default: object }>(); + return { default: { ...original.default, ...mcpApi } }; +}); + // The OAuth popup is covered by ConnectorAuth's own tests; here it only has // to report a finished sign-in. vi.mock('../components/ConnectorAuth', () => ({ @@ -55,6 +64,9 @@ vi.mock('../components/FilePicker', () => ({ ), })); +import notificationsReducer, { + sseEventReceived, +} from '../notifications/notificationsSlice'; import connectorsReducer from './connectorsSlice'; import ConnectWizard from './ConnectWizard'; import type { ConnectorDefinition } from './types'; @@ -147,6 +159,7 @@ describe('ConnectWizard', () => { const store = configureStore({ reducer: { connectors: connectorsReducer, + notifications: notificationsReducer, preference: (state = { token: null, selectedDocs: [] }) => state, conversation: (state = {}) => state, }, @@ -168,6 +181,7 @@ describe('ConnectWizard', () => { , ); }); + return store; }; const click = async (text: string) => { @@ -292,4 +306,87 @@ describe('ConnectWizard', () => { expect(onClose).toHaveBeenCalled(); expect(document.body.textContent).toContain('NEW_CHAT'); }); + + it('signs in to an MCP preset with one button and shows its tools', async () => { + const notion: ConnectorDefinition = { + ...base, + key: 'mcp:notion', + name: 'Notion', + icon: 'notion', + auth_kind: 'mcp_oauth', + credential_fields: [], + tool_templates: ['mcp_tool'], + mcp_url: 'https://mcp.notion.com/mcp', + oauth_scopes: [], + publisher: 'preset', + }; + const popup = { closed: false, close: vi.fn(), location: { href: '' } }; + const open = vi.spyOn(window, 'open').mockReturnValue(popup as never); + mcpApi.testMCPConnection.mockResolvedValue({ + json: async () => ({ requires_oauth: true, task_id: 'task-1' }), + }); + mcpApi.saveMCPServer.mockResolvedValue({ + ok: true, + json: async () => ({ success: true, id: 'tool-9' }), + }); + service.listConnections.mockResolvedValue({ + success: true, + connections: [ + { id: 'conn-9', connector_key: 'mcp:notion', updated_at: '2026-09-28' }, + ], + }); + service.getConnection.mockResolvedValue({ + success: true, + connection: { tools: [{ ...TELEGRAM_TOOL, display_name: 'Notion' }] }, + }); + const store = await render(notion); + // No server URL or auth form: one sign-in button. + expect(document.body.querySelector('input')).toBeNull(); + await click('settings.connectors.wizard.signIn'); + // The pop-up opens inside the click, then follows the worker. + expect(open).toHaveBeenCalledWith( + 'about:blank', + 'mcpOAuth', + expect.any(String), + ); + expect(document.body.textContent).toContain( + 'settings.connectors.wizard.waiting', + ); + await act(async () => { + store.dispatch( + sseEventReceived({ + id: 'ev-1', + type: 'mcp.oauth.awaiting_redirect', + scope: { kind: 'mcp_oauth', id: 'task-1' }, + payload: { authorization_url: 'https://notion.example/authorize' }, + }), + ); + }); + expect(popup.location.href).toBe('https://notion.example/authorize'); + await act(async () => { + store.dispatch( + sseEventReceived({ + id: 'ev-2', + type: 'mcp.oauth.completed', + scope: { kind: 'mcp_oauth', id: 'task-1' }, + payload: { tools: [] }, + }), + ); + }); + expect(mcpApi.saveMCPServer.mock.calls[0][0]).toMatchObject({ + displayName: 'Notion', + config: { + server_url: 'https://mcp.notion.com/mcp', + auth_type: 'oauth', + oauth_task_id: 'task-1', + }, + }); + expect(document.body.textContent).toContain( + 'settings.connectors.wizard.doneTitle', + ); + expect(document.body.textContent).toContain( + 'settings.connectors.wizard.toolsHeading:1', + ); + open.mockRestore(); + }); }); diff --git a/frontend/src/connectors/ConnectWizard.tsx b/frontend/src/connectors/ConnectWizard.tsx index b3f648e1..547095f1 100644 --- a/frontend/src/connectors/ConnectWizard.tsx +++ b/frontend/src/connectors/ConnectWizard.tsx @@ -6,7 +6,9 @@ import { useDispatch, useSelector } from 'react-redux'; import { useNavigate } from 'react-router-dom'; import { envVar } from '@/env'; +import { baseURL } from '../api/client'; import connectorsService from '../api/services/connectorsService'; +import userService from '../api/services/userService'; import { useConnectorAuth } from '../components/ConnectorAuth'; import { FilePicker } from '../components/FilePicker'; import GoogleDrivePicker from '../components/GoogleDrivePicker'; @@ -17,6 +19,7 @@ import { AccordionTrigger, } from '../components/ui/accordion'; import { Alert, AlertDescription } from '../components/ui/alert'; +import { Button } from '../components/ui/button'; import { FormField } from '../components/ui/form-field'; import { Input } from '../components/ui/input'; import { Modal, ModalActions } from '../components/ui/modal'; @@ -43,6 +46,7 @@ import CredentialForm, { credentialsComplete } from './CredentialForm'; import { loadConnectors, selectConnections } from './connectorsSlice'; import { connectorDescription, connectorName } from './i18n'; import ToolPermissions from './ToolPermissions'; +import useMcpOAuth, { type McpOAuthConfig } from './useMcpOAuth'; import type { ConnectionTool, ConnectorDefinition } from './types'; export type WizardMode = 'connect' | 'reconnect' | 'sync' | 'done'; @@ -66,12 +70,15 @@ export default function ConnectWizard({ connector, mode = 'connect', connectionId: initialConnectionId = null, + mcpToolId, onClose, onFinished, }: { connector: ConnectorDefinition; mode?: WizardMode; connectionId?: string | null; + /** Reconnecting an MCP preset: the tool to update rather than add. */ + mcpToolId?: string; onClose: () => void; onFinished?: () => void; }) { @@ -135,6 +142,73 @@ export default function ConnectWizard({ setStep(canSync ? 'setup' : 'done'); }; + // MCP presets (Notion, Linear…) sign in over MCP OAuth: no URL or auth + // form, just "Sign in to Notion". The tool is saved on success, and the + // done step shows what it can do. + const isMcpPreset = + connector.auth_kind === 'mcp_oauth' && !!connector.mcp_url; + const mcp = useMcpOAuth(); + const mcpConfig = (): McpOAuthConfig => ({ + server_url: connector.mcp_url ?? '', + auth_type: 'oauth', + oauth_scopes: connector.oauth_scopes ?? [], + timeout: 30, + redirect_uri: `${baseURL.replace(/\/$/, '')}/api/mcp_server/callback`, + }); + + const saveMcp = async (config: McpOAuthConfig, taskId: string | null) => { + setPending(true); + try { + const response = await userService.saveMCPServer( + { + displayName: name, + config: { ...config, oauth_task_id: taskId ?? '' }, + status: true, + ...(mcpToolId && { id: mcpToolId }), + }, + token, + ); + const result = await response.json(); + if (!response.ok || !result.success) throw new Error(result.error); + const list = await connectorsService.listConnections(token); + const saved = ( + (list?.connections ?? []) as { + id: string; + connector_key: string; + updated_at: string | null; + }[] + ) + .filter((c) => c.connector_key === connector.key) + .sort((a, b) => + (b.updated_at ?? '').localeCompare(a.updated_at ?? ''), + )[0]; + refresh(); + if (saved) { + setConnectionId(saved.id); + const detail = await connectorsService.getConnection(saved.id, token); + if (detail?.success) setTools(detail.connection.tools ?? []); + } + setStep('done'); + } catch (err) { + setError( + (err instanceof Error && err.message) || + t('settings.tools.mcp.errors.saveFailed'), + ); + } finally { + setPending(false); + } + }; + + const startMcpSignIn = () => { + setError(''); + const config = mcpConfig(); + mcp.start(config, { + onDone: ({ taskId }) => saveMcp(config, taskId), + onError: (message) => + setError(message || t('settings.tools.mcp.errors.oauthFailed')), + }); + }; + const startSignIn = useConnectorAuth({ provider: connector.key, connectionId: @@ -300,7 +374,25 @@ export default function ConnectWizard({ {error} )} - {connector.auth_kind === 'oauth' ? null : ( + {isMcpPreset && mcp.pending && ( +

+ {t('settings.connectors.wizard.waiting', { + name, + interpolation: { escapeValue: false }, + })} +

+ )} + {isMcpPreset && mcp.blockedUrl && ( + + )} + {connector.auth_kind === 'oauth' || isMcpPreset ? null : ( { + mcp.cancel(); + onClose(); + }} submitLabel={t('settings.connectors.wizard.signIn', { name, interpolation: { escapeValue: false }, })} - onSubmit={startSignIn} + onSubmit={isMcpPreset ? startMcpSignIn : startSignIn} + pending={isMcpPreset && (mcp.pending || pending)} /> ) : ( + connector.auth_kind === 'mcp_oauth' && !!connector.mcp_url; + +/** Connectors whose sign-in the wizard redoes in place, with no form. */ +export const reconnectsInPlace = (connector: ConnectorDefinition) => + connector.auth_kind === 'oauth' || + connector.auth_kind === 'api_key' || + isMcpPreset(connector); diff --git a/frontend/src/connectors/useConnectorLauncher.tsx b/frontend/src/connectors/useConnectorLauncher.tsx index 696bf86e..f461a12b 100644 --- a/frontend/src/connectors/useConnectorLauncher.tsx +++ b/frontend/src/connectors/useConnectorLauncher.tsx @@ -13,6 +13,7 @@ import { selectToken } from '../preferences/preferenceSlice'; import type { AppDispatch } from '../store'; import ConnectWizard, { type WizardMode } from './ConnectWizard'; import { loadConnectors } from './connectorsSlice'; +import { isMcpPreset } from './launchRules'; import type { ConnectorDefinition } from './types'; export type LaunchOptions = { @@ -29,6 +30,7 @@ type Active = connector: ConnectorDefinition; mode: WizardMode; connectionId?: string; + mcpToolId?: string; } | { kind: 'mcp'; @@ -61,6 +63,19 @@ export default function useConnectorLauncher({ const launch = useCallback( async (connector: ConnectorDefinition, options: LaunchOptions = {}) => { + if (isMcpPreset(connector)) { + setActive({ + kind: 'wizard', + connector, + mode: options.mode === 'reconnect' ? 'reconnect' : 'connect', + connectionId: options.connectionId, + mcpToolId: + typeof options.mcpServer?.id === 'string' + ? options.mcpServer.id + : undefined, + }); + return; + } if (isMcp(connector)) { setActive({ kind: 'mcp', @@ -158,6 +173,7 @@ export default function useConnectorLauncher({ connector={active.connector} mode={active.mode} connectionId={active.connectionId} + mcpToolId={active.mcpToolId} onClose={() => { setActive(null); refresh(); diff --git a/frontend/src/connectors/useMcpOAuth.ts b/frontend/src/connectors/useMcpOAuth.ts new file mode 100644 index 00000000..705ef9ce --- /dev/null +++ b/frontend/src/connectors/useMcpOAuth.ts @@ -0,0 +1,125 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useSelector } from 'react-redux'; + +import userService from '../api/services/userService'; +import { selectRecentEvents } from '../notifications/notificationsSlice'; +import { selectToken } from '../preferences/preferenceSlice'; + +export type McpOAuthConfig = { + server_url: string; + auth_type: 'oauth'; + oauth_scopes: string[]; + timeout: number; + redirect_uri: string; +}; + +type Handlers = { + /** Signed in: `taskId` is the OAuth task to save with (null when the + * server already had a valid sign-in). */ + onDone: (result: { taskId: string | null }) => void; + onError: (message: string) => void; +}; + +/** + * Sign in to an MCP server over OAuth from a click. The pop-up opens blank + * inside the click (so the browser allows it) and follows the worker's + * `mcp.oauth.*` events: pointed at the provider on `awaiting_redirect`, + * closed on `completed` or `failed`. `blockedUrl` is set when the browser + * blocked the pop-up anyway, so the caller can offer the link. + */ +export default function useMcpOAuth() { + const token = useSelector(selectToken); + const events = useSelector(selectRecentEvents); + const [taskId, setTaskId] = useState(null); + const [pending, setPending] = useState(false); + const [blockedUrl, setBlockedUrl] = useState(null); + const popupRef = useRef(null); + const handlersRef = useRef(null); + const handledRef = useRef>(new Set()); + + const closePopup = () => { + if (popupRef.current && !popupRef.current.closed) popupRef.current.close(); + popupRef.current = null; + }; + + const finish = useCallback(() => { + closePopup(); + setTaskId(null); + setPending(false); + handlersRef.current = null; + }, []); + + useEffect(() => finish, [finish]); + + const start = useCallback( + async (config: McpOAuthConfig, handlers: Handlers) => { + handlersRef.current = handlers; + handledRef.current = new Set(); + setBlockedUrl(null); + setPending(true); + closePopup(); + popupRef.current = window.open( + 'about:blank', + 'mcpOAuth', + 'width=600,height=700', + ); + try { + const response = await userService.testMCPConnection({ config }, token); + const result = await response.json(); + if (result.requires_oauth && result.task_id) { + setTaskId(result.task_id); + return; + } + const done = handlersRef.current; + finish(); + if (result.success) done?.onDone({ taskId: null }); + else done?.onError(result.message || result.error || ''); + } catch { + const done = handlersRef.current; + finish(); + done?.onError(''); + } + }, + [token, finish], + ); + + useEffect(() => { + if (!taskId) return; + // Newest first in the slice; walk oldest first so a redirect buffered + // before its completion still opens the provider. + for (let i = events.length - 1; i >= 0; i--) { + const event = events[i]; + if (event.scope?.id !== taskId || !event.id) continue; + if (handledRef.current.has(event.id)) continue; + handledRef.current.add(event.id); + const payload = (event.payload ?? {}) as Record; + if (event.type === 'mcp.oauth.awaiting_redirect') { + const url = payload.authorization_url as string | undefined; + if (!url) continue; + if (popupRef.current && !popupRef.current.closed) { + popupRef.current.location.href = url; + } else { + popupRef.current = window.open( + url, + 'mcpOAuth', + 'width=600,height=700', + ); + if (!popupRef.current) setBlockedUrl(url); + } + } else if (event.type === 'mcp.oauth.completed') { + const done = handlersRef.current; + const id = taskId; + finish(); + done?.onDone({ taskId: id }); + return; + } else if (event.type === 'mcp.oauth.failed') { + const done = handlersRef.current; + finish(); + done?.onError(String(payload.error ?? '')); + return; + } + } + }, [events, taskId, finish]); + + return { start, cancel: finish, pending, blockedUrl }; +} diff --git a/frontend/src/conversation/ConnectToolCallBar.tsx b/frontend/src/conversation/ConnectToolCallBar.tsx index eb12bd0e..f014c1bb 100644 --- a/frontend/src/conversation/ConnectToolCallBar.tsx +++ b/frontend/src/conversation/ConnectToolCallBar.tsx @@ -12,6 +12,7 @@ import { selectConnectorCatalog, selectConnectorsLoaded, } from '../connectors/connectorsSlice'; +import { reconnectsInPlace } from '../connectors/launchRules'; import useConnectorLauncher from '../connectors/useConnectorLauncher'; import { selectToken } from '../preferences/preferenceSlice'; import type { AppDispatch } from '../store'; @@ -59,13 +60,13 @@ export default function ConnectToolCallBar({ const connect = () => { // No account yet: connect one. The caller's own account that needs // signing in again is reconnected right here (every tool and source on - // it heals). MCP servers and anyone else's account go to the drawer. + // it heals). Custom MCP servers and anyone else's account go to the + // drawer. if (connector && required?.status === 'missing') { launch(connector); return; } - const inPlace = - connector?.auth_kind === 'oauth' || connector?.auth_kind === 'api_key'; + const inPlace = !!connector && reconnectsInPlace(connector); if (connector && required?.connection_id && inPlace) { launch(connector, { mode: 'reconnect', diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json index 620e0348..d5523c49 100644 --- a/frontend/src/locale/de.json +++ b/frontend/src/locale/de.json @@ -1240,7 +1240,9 @@ "connectedWithKey": "Verbunden mit dem Schlüssel, der auf {{hint}} endet.", "doneCounts": "{{sources}} werden synchronisiert, {{tools}} bereit.", "doneSources": "{{sources}} werden synchronisiert.", - "doneTools": "{{tools}} bereit." + "doneTools": "{{tools}} bereit.", + "waiting": "Schließe die Anmeldung bei {{name}} im geöffneten Fenster ab.", + "openSignIn": "Anmeldung bei {{name}} öffnen" }, "remove": { "title": "{{name}} entfernen?", diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index ce555adf..75759a9f 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -1246,7 +1246,9 @@ "connectedWithKey": "Connected with the key ending {{hint}}.", "doneCounts": "{{sources}} syncing, {{tools}} ready.", "doneSources": "{{sources}} syncing.", - "doneTools": "{{tools}} ready." + "doneTools": "{{tools}} ready.", + "waiting": "Finish signing in to {{name}} in the window that opened.", + "openSignIn": "Open the {{name}} sign-in" }, "remove": { "title": "Remove {{name}}?", diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index a96888e4..0954f5f1 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -1240,7 +1240,9 @@ "connectedWithKey": "Conectado con la clave que termina en {{hint}}.", "doneCounts": "{{sources}} sincronizando, {{tools}} listas.", "doneSources": "{{sources}} sincronizando.", - "doneTools": "{{tools}} listas." + "doneTools": "{{tools}} listas.", + "waiting": "Termina de iniciar sesión en {{name}} en la ventana que se abrió.", + "openSignIn": "Abrir el inicio de sesión de {{name}}" }, "remove": { "title": "¿Eliminar {{name}}?", diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json index a6123e25..95ff6834 100644 --- a/frontend/src/locale/jp.json +++ b/frontend/src/locale/jp.json @@ -1228,7 +1228,9 @@ "connectedWithKey": "末尾が {{hint}} のキーで接続しました。", "doneCounts": "{{sources}}を同期中、{{tools}}を利用できます。", "doneSources": "{{sources}}を同期中です。", - "doneTools": "{{tools}}を利用できます。" + "doneTools": "{{tools}}を利用できます。", + "waiting": "開いたウィンドウで {{name}} へのサインインを完了してください。", + "openSignIn": "{{name}} のサインインを開く" }, "remove": { "title": "{{name}} を削除しますか?", diff --git a/frontend/src/locale/ru.json b/frontend/src/locale/ru.json index 56767fd3..09dc9661 100644 --- a/frontend/src/locale/ru.json +++ b/frontend/src/locale/ru.json @@ -1302,7 +1302,9 @@ "connectedWithKey": "Подключено ключом, оканчивающимся на {{hint}}.", "doneCounts": "Синхронизируется: {{sources}}, готово: {{tools}}.", "doneSources": "Синхронизируется: {{sources}}.", - "doneTools": "Готово: {{tools}}." + "doneTools": "Готово: {{tools}}.", + "waiting": "Завершите вход в {{name}} в открывшемся окне.", + "openSignIn": "Открыть вход в {{name}}" }, "remove": { "title": "Удалить {{name}}?", diff --git a/frontend/src/locale/zh-TW.json b/frontend/src/locale/zh-TW.json index ef07510b..0cc3bfde 100644 --- a/frontend/src/locale/zh-TW.json +++ b/frontend/src/locale/zh-TW.json @@ -1228,7 +1228,9 @@ "connectedWithKey": "已使用結尾為 {{hint}} 的金鑰連線。", "doneCounts": "{{sources}}同步中,{{tools}}已就緒。", "doneSources": "{{sources}}同步中。", - "doneTools": "{{tools}}已就緒。" + "doneTools": "{{tools}}已就緒。", + "waiting": "請在開啟的視窗中完成 {{name}} 登入。", + "openSignIn": "開啟 {{name}} 登入" }, "remove": { "title": "要移除 {{name}} 嗎?", diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json index 68a9173b..b9191446 100644 --- a/frontend/src/locale/zh.json +++ b/frontend/src/locale/zh.json @@ -1228,7 +1228,9 @@ "connectedWithKey": "已使用以 {{hint}} 结尾的密钥连接。", "doneCounts": "{{sources}}正在同步,{{tools}}已就绪。", "doneSources": "{{sources}}正在同步。", - "doneTools": "{{tools}}已就绪。" + "doneTools": "{{tools}}已就绪。", + "waiting": "请在打开的窗口中完成 {{name}} 登录。", + "openSignIn": "打开 {{name}} 登录" }, "remove": { "title": "移除 {{name}}?", diff --git a/frontend/src/modals/MCPServerModal.tsx b/frontend/src/modals/MCPServerModal.tsx index d8a38ba8..d0c150a1 100644 --- a/frontend/src/modals/MCPServerModal.tsx +++ b/frontend/src/modals/MCPServerModal.tsx @@ -94,6 +94,7 @@ export default function MCPServerModal({ const popupOpenedRef = useRef(false); const [oauthCompleted, setOAuthCompleted] = useState(false); const [saveActive, setSaveActive] = useState(false); + const [showAdvanced, setShowAdvanced] = useState(false); const cleanupOAuthListener = useCallback(() => { setOauthTaskId(null); @@ -526,19 +527,6 @@ export default function MCPServerModal({ ); - case 'oauth': - return ( - - - handleInputChange('oauth_scopes', e.target.value) - } - placeholder="read, write" - /> - - ); default: return null; } @@ -660,29 +648,59 @@ export default function MCPServerModal({ {renderAuthFields()} - + {formData.auth_type === 'oauth' && ( + + + handleInputChange('oauth_scopes', e.target.value) + } + placeholder="read, write" + /> + + )} + + { + const value = e.target.value; + if (value === '') { + handleInputChange('timeout', ''); + } else { + const numValue = parseInt(value); + if (!isNaN(numValue) && numValue >= 1) { + handleInputChange('timeout', numValue); + } + } + }} + placeholder="30" + min={1} + max={300} + /> + + + ) : null} + {testResult && ( diff --git a/frontend/src/notifications/ConnectionHealthToast.tsx b/frontend/src/notifications/ConnectionHealthToast.tsx index 236509e0..2fa05ff7 100644 --- a/frontend/src/notifications/ConnectionHealthToast.tsx +++ b/frontend/src/notifications/ConnectionHealthToast.tsx @@ -18,6 +18,7 @@ import { selectConnectorCatalog, selectConnectorsLoaded, } from '../connectors/connectorsSlice'; +import { reconnectsInPlace } from '../connectors/launchRules'; import useConnectorLauncher from '../connectors/useConnectorLauncher'; import { formatCount } from '../utils/dateTimeUtils'; import { selectToken } from '../preferences/preferenceSlice'; @@ -106,8 +107,7 @@ export default function ConnectionHealthToast() { const reconnect = (payload: Record) => { const connectionId = String(payload.connection_id ?? ''); const connector = catalog.find((c) => c.key === payload.connector_key); - const inPlace = - connector?.auth_kind === 'oauth' || connector?.auth_kind === 'api_key'; + const inPlace = !!connector && reconnectsInPlace(connector); if (connector && connectionId && inPlace) { launch(connector, { mode: 'reconnect', connectionId }); return true; @@ -137,7 +137,7 @@ export default function ConnectionHealthToast() { - {/* Sign in again right here; MCP servers reconnect from the + {/* Sign in again right here; custom MCP servers reconnect from the connector's drawer. The toast stays until it works. */}