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.
This commit is contained in:
arc53-machine committed 2026-09-28 21:42:50 +01:00
1 parent f6c89503d8
commit bfcc4a9773
18 files changed
+470 -57

No files matched your search

@@ -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.
</Steps>
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
+9 -2
View File
@@ -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(
@@ -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', () => {
</Provider>,
);
});
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();
});
});
+100 -4
View File
@@ -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({
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{connector.auth_kind === 'oauth' ? null : (
{isMcpPreset && mcp.pending && (
<p className="text-muted-foreground text-sm">
{t('settings.connectors.wizard.waiting', {
name,
interpolation: { escapeValue: false },
})}
</p>
)}
{isMcpPreset && mcp.blockedUrl && (
<Button variant="link" size="inline" asChild className="w-fit">
<a href={mcp.blockedUrl} target="_blank" rel="noopener noreferrer">
{t('settings.connectors.wizard.openSignIn', {
name,
interpolation: { escapeValue: false },
})}
</a>
</Button>
)}
{connector.auth_kind === 'oauth' || isMcpPreset ? null : (
<CredentialForm
connectorKey={connector.key}
idPrefix={`connect-${connector.key}`}
@@ -432,15 +524,19 @@ export default function ConnectWizard({
const footer =
step === 'signin' ? (
connector.auth_kind === 'oauth' ? (
connector.auth_kind === 'oauth' || isMcpPreset ? (
<ModalActions
cancelLabel={t('cancel')}
onCancel={onClose}
onCancel={() => {
mcp.cancel();
onClose();
}}
submitLabel={t('settings.connectors.wizard.signIn', {
name,
interpolation: { escapeValue: false },
})}
onSubmit={startSignIn}
onSubmit={isMcpPreset ? startMcpSignIn : startSignIn}
pending={isMcpPreset && (mcp.pending || pending)}
/>
) : (
<ModalActions
+11
View File
@@ -0,0 +1,11 @@
import type { ConnectorDefinition } from './types';
/** An MCP preset that signs in over OAuth: it goes through the wizard. */
export const isMcpPreset = (connector: ConnectorDefinition) =>
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);
@@ -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();
+125
View File
@@ -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<string | null>(null);
const [pending, setPending] = useState(false);
const [blockedUrl, setBlockedUrl] = useState<string | null>(null);
const popupRef = useRef<Window | null>(null);
const handlersRef = useRef<Handlers | null>(null);
const handledRef = useRef<Set<string>>(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<string, unknown>;
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 };
}
@@ -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',
+3 -1
View File
@@ -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?",
+3 -1
View File
@@ -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}}?",
+3 -1
View File
@@ -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}}?",
+3 -1
View File
@@ -1228,7 +1228,9 @@
"connectedWithKey": "末尾が {{hint}} のキーで接続しました。",
"doneCounts": "{{sources}}を同期中、{{tools}}を利用できます。",
"doneSources": "{{sources}}を同期中です。",
"doneTools": "{{tools}}を利用できます。"
"doneTools": "{{tools}}を利用できます。",
"waiting": "開いたウィンドウで {{name}} へのサインインを完了してください。",
"openSignIn": "{{name}} のサインインを開く"
},
"remove": {
"title": "{{name}} を削除しますか?",
+3 -1
View File
@@ -1302,7 +1302,9 @@
"connectedWithKey": "Подключено ключом, оканчивающимся на {{hint}}.",
"doneCounts": "Синхронизируется: {{sources}}, готово: {{tools}}.",
"doneSources": "Синхронизируется: {{sources}}.",
"doneTools": "Готово: {{tools}}."
"doneTools": "Готово: {{tools}}.",
"waiting": "Завершите вход в {{name}} в открывшемся окне.",
"openSignIn": "Открыть вход в {{name}}"
},
"remove": {
"title": "Удалить {{name}}?",
+3 -1
View File
@@ -1228,7 +1228,9 @@
"connectedWithKey": "已使用結尾為 {{hint}} 的金鑰連線。",
"doneCounts": "{{sources}}同步中,{{tools}}已就緒。",
"doneSources": "{{sources}}同步中。",
"doneTools": "{{tools}}已就緒。"
"doneTools": "{{tools}}已就緒。",
"waiting": "請在開啟的視窗中完成 {{name}} 登入。",
"openSignIn": "開啟 {{name}} 登入"
},
"remove": {
"title": "要移除 {{name}} 嗎?",
+3 -1
View File
@@ -1228,7 +1228,9 @@
"connectedWithKey": "已使用以 {{hint}} 结尾的密钥连接。",
"doneCounts": "{{sources}}正在同步,{{tools}}已就绪。",
"doneSources": "{{sources}}正在同步。",
"doneTools": "{{tools}}已就绪。"
"doneTools": "{{tools}}已就绪。",
"waiting": "请在打开的窗口中完成 {{name}} 登录。",
"openSignIn": "打开 {{name}} 登录"
},
"remove": {
"title": "移除 {{name}}?",
+53 -35
View File
@@ -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({
</FormField>
</div>
);
case 'oauth':
return (
<FormField label={t('settings.tools.mcp.placeholders.oauthScopes')}>
<Input
type="text"
value={formData.oauth_scopes}
onChange={(e) =>
handleInputChange('oauth_scopes', e.target.value)
}
placeholder="read, write"
/>
</FormField>
);
default:
return null;
}
@@ -660,29 +648,59 @@ export default function MCPServerModal({
{renderAuthFields()}
<FormField
label={t('settings.tools.mcp.timeout')}
error={errors.timeout}
{/* Scopes and timeout rarely need changing: behind Advanced. */}
{showAdvanced || errors.timeout ? (
<>
{formData.auth_type === 'oauth' && (
<FormField
label={t('settings.tools.mcp.placeholders.oauthScopes')}
>
<Input
type="text"
value={formData.oauth_scopes}
onChange={(e) =>
handleInputChange('oauth_scopes', e.target.value)
}
placeholder="read, write"
/>
</FormField>
)}
<FormField
label={t('settings.tools.mcp.timeout')}
error={errors.timeout}
>
<Input
type="number"
value={formData.timeout}
onChange={(e) => {
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}
/>
</FormField>
</>
) : null}
<Button
type="button"
variant="link"
size="sm"
onClick={() => setShowAdvanced((open) => !open)}
className="-ml-3 w-fit justify-start"
>
<Input
type="number"
value={formData.timeout}
onChange={(e) => {
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}
/>
</FormField>
{showAdvanced
? t('modals.uploadDoc.hideAdvanced')
: t('modals.uploadDoc.showAdvanced')}
</Button>
{testResult && (
<Alert variant={testResult.success ? 'success' : 'destructive'}>
@@ -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<string, unknown>) => {
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() {
</ToastActions>
</ToastHeader>
<ToastFooter>
{/* 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. */}
<Button asChild size="sm" shape="pill">
<Link
+29 -1
View File
@@ -328,7 +328,10 @@ class TestMCPServerSave:
def test_oauth_missing_task_id_returns_400(self, app):
from docsgpt.api.user.tools.mcp import MCPServerSave
with app.test_request_context(
# No OAuth task and no stored sign-in that works.
unauthorized = MagicMock()
unauthorized.discover_tools.side_effect = RuntimeError("401")
with patch("docsgpt.api.user.tools.mcp.MCPTool", return_value=unauthorized), app.test_request_context(
"/api/mcp_server/save", method="POST",
json={
"displayName": "Srv",
@@ -344,6 +347,31 @@ class TestMCPServerSave:
response = MCPServerSave().post()
assert response.status_code == 400
def test_oauth_without_task_uses_the_stored_sign_in(self, app, pg_conn):
"""Signed in before: the server answers with the saved tokens, no new handshake."""
from docsgpt.api.user.tools.mcp import MCPServerSave
signed_in = MagicMock()
signed_in.get_actions_metadata.return_value = [{"name": "search"}]
with _patch_db(pg_conn), patch(
"docsgpt.api.user.tools.mcp.MCPTool", return_value=signed_in,
), patch("docsgpt.api.user.tools.mcp._mcp_connection", return_value=None), app.test_request_context(
"/api/mcp_server/save", method="POST",
json={
"displayName": "Linear",
"config": {
"transport_type": "http",
"server_url": "https://mcp.linear.app/mcp",
"auth_type": "oauth",
},
},
):
from flask import request
request.decoded_token = {"sub": "u-signed-in"}
response = MCPServerSave().post()
assert response.status_code == 200
signed_in.discover_tools.assert_called_once()
def test_creates_mcp_tool_successfully(self, app, pg_conn):
from docsgpt.api.user.tools.mcp import MCPServerSave