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({
+ {t('settings.connectors.wizard.waiting', { + name, + interpolation: { escapeValue: false }, + })} +
+ )} + {isMcpPreset && mcp.blockedUrl && ( + + )} + {connector.auth_kind === 'oauth' || isMcpPreset ? null : (