From bfcc4a977337b7d3ec56a86255558eac86177de3 Mon Sep 17 00:00:00 2001 From: arc53-machine <232052973+arc53-machine@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:42:50 +0100 Subject: [PATCH] Sign in to MCP presets from the connect wizard Notion, Linear, Atlassian, Sentry, Asana and Stripe no longer open the MCP server form. The wizard shows one "Sign in to Notion" button: the pop-up opens inside the click (so browsers allow it), follows the worker's mcp.oauth events to the provider, and the tool is saved on success with its actions on the done step. Reconnecting a preset uses the same flow, as do the chat bar and the health toast. Saving an OAuth MCP server without a new handshake now uses the stored sign-in instead of failing, and the custom MCP form keeps scopes and timeout under Show advanced. --- .../Integrations/mcp-tool-integration.mdx | 4 +- docsgpt/api/user/tools/mcp.py | 11 +- .../src/connectors/ConnectWizard.test.tsx | 97 ++++++++++++++ frontend/src/connectors/ConnectWizard.tsx | 104 ++++++++++++++- frontend/src/connectors/launchRules.ts | 11 ++ .../src/connectors/useConnectorLauncher.tsx | 16 +++ frontend/src/connectors/useMcpOAuth.ts | 125 ++++++++++++++++++ .../src/conversation/ConnectToolCallBar.tsx | 7 +- frontend/src/locale/de.json | 4 +- frontend/src/locale/en.json | 4 +- frontend/src/locale/es.json | 4 +- frontend/src/locale/jp.json | 4 +- frontend/src/locale/ru.json | 4 +- frontend/src/locale/zh-TW.json | 4 +- frontend/src/locale/zh.json | 4 +- frontend/src/modals/MCPServerModal.tsx | 88 +++++++----- .../notifications/ConnectionHealthToast.tsx | 6 +- tests/api/user/test_tools_mcp_pg.py | 30 ++++- 18 files changed, 470 insertions(+), 57 deletions(-) create mode 100644 frontend/src/connectors/launchRules.ts create mode 100644 frontend/src/connectors/useMcpOAuth.ts 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. */}