From 97e37a7e37bb93ebfd3ff4dd6f02d262f70d3955 Mon Sep 17 00:00:00 2001 From: arc53-machine <232052973+arc53-machine@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:54:07 +0100 Subject: [PATCH] Manage a connected tool on its connector page A tool from a connection shows the catalog's description and a Manage connection button instead of its own switch, and its menu no longer opens the raw MCP server form to reconnect. It says when it is off or needs signing in again. --- frontend/src/connectors/connectorsSlice.ts | 11 +- frontend/src/settings/Tools.test.tsx | 74 +++++- frontend/src/settings/Tools.tsx | 248 +++++++++++---------- 3 files changed, 216 insertions(+), 117 deletions(-) diff --git a/frontend/src/connectors/connectorsSlice.ts b/frontend/src/connectors/connectorsSlice.ts index 5ab08500..1bd96d51 100644 --- a/frontend/src/connectors/connectorsSlice.ts +++ b/frontend/src/connectors/connectorsSlice.ts @@ -86,12 +86,15 @@ export const selectConnectorCatalog = (state: RootLike) => state.connectors.catalog; export const selectConnections = (state: RootLike) => state.connectors.connections; +/** Whether a connection needs the user to sign in again (expired or failing). */ +export const connectionNeedsSignIn = ( + connection: { status: string } | null | undefined, +) => + connection?.status === 'reconnect_needed' || connection?.status === 'error'; + /** A connection that needs the user (signing in again, or failing). */ export const selectConnectionsNeedAttention = (state: RootLike) => - (state.connectors?.connections ?? []).some( - (connection) => - connection.status === 'reconnect_needed' || connection.status === 'error', - ); + (state.connectors?.connections ?? []).some(connectionNeedsSignIn); export const selectConnectorsLoading = (state: RootLike) => state.connectors.loading; export const selectConnectorsLoaded = (state: RootLike) => diff --git a/frontend/src/settings/Tools.test.tsx b/frontend/src/settings/Tools.test.tsx index 63db1694..2a484cc1 100644 --- a/frontend/src/settings/Tools.test.tsx +++ b/frontend/src/settings/Tools.test.tsx @@ -31,6 +31,17 @@ const TOOLS = [ actions: [], connection_id: null, }, + { + id: 'lin', + name: 'mcp_tool', + displayName: 'Linear', + customName: '', + description: 'MCP Server: https://mcp.linear.app/mcp', + status: true, + config: {}, + actions: [], + connection_id: 'conn-2', + }, ]; vi.mock('../api/services/userService', () => ({ @@ -46,7 +57,27 @@ vi.mock('../api/services/userService', () => ({ })); vi.mock('../api/services/connectorsService', () => ({ default: { - getCatalog: vi.fn(async () => ({ success: true, connectors: [] })), + getCatalog: vi.fn(async () => ({ + success: true, + connectors: [ + { + key: 'telegram', + name: 'Telegram', + description: 'Send messages to a chat.', + publisher: 'built_in', + available: true, + capabilities: ['write'], + }, + { + key: 'mcp:linear', + name: 'Linear', + description: 'Issues and projects.', + publisher: 'preset', + available: true, + capabilities: ['write'], + }, + ], + })), listConnections: vi.fn(async () => ({ success: true, connections: [ @@ -58,6 +89,14 @@ vi.mock('../api/services/connectorsService', () => ({ status: 'connected', account_label: '…abcd', }, + { + id: 'conn-2', + connector_key: 'mcp:linear', + name: 'Linear', + icon: 'linear', + status: 'reconnect_needed', + account_label: 'Linear', + }, ], })), }, @@ -142,10 +181,43 @@ describe('Tools page', () => { ).toBe('/settings/connectors?connector=telegram'); }); + const card = (title: string) => + Array.from(container.querySelectorAll('h2')) + .find((h) => h.textContent === title)! + .closest('[data-slot="card"]')!; + + it('manages a connected tool on its connector page, not with its own switch', async () => { + await render(); + const telegram = card('Telegram'); + expect(telegram.querySelector('[role="switch"]')).toBeNull(); + // The catalog's plain description, not the tool's setup notes. + expect(telegram.textContent).toContain( + 'settings.connectors.descriptions.telegram', + ); + const manage = Array.from(telegram.querySelectorAll('button')).find( + (b) => b.textContent === 'settings.connectors.manageConnection', + )!; + await act(async () => manage.click()); + expect( + document.body.querySelector('[data-testid="where"]')?.textContent, + ).toBe('/settings/connectors?connector=telegram'); + }); + + it('says a connected tool needs signing in again, with no raw MCP reconnect', async () => { + await render(); + expect(card('Linear').textContent).toContain( + 'settings.connectors.health.signInAgain', + ); + expect(card('Linear').textContent).not.toContain('MCP Server:'); + await openMenu(2); + expect(menuItem('settings.tools.reconnect')).toBeUndefined(); + }); + it('keeps Edit for a tool that is not from a connection', async () => { await render(); await openMenu(1); expect(menuItem('settings.tools.edit')).toBeDefined(); expect(menuItem('settings.connectors.manageConnection')).toBeUndefined(); + expect(card('My API').querySelector('[role="switch"]')).not.toBeNull(); }); }); diff --git a/frontend/src/settings/Tools.tsx b/frontend/src/settings/Tools.tsx index 6d6c42b4..16410868 100644 --- a/frontend/src/settings/Tools.tsx +++ b/frontend/src/settings/Tools.tsx @@ -23,10 +23,12 @@ import { ActionMenu, type MenuOption } from '../components/ui/dropdown-menu'; import { EmptyState } from '../components/ui/empty-state'; import ConnectorIcon from '../connectors/ConnectorIcon'; import { + connectionNeedsSignIn, loadConnectors, selectConnections, selectConnectorCatalog, } from '../connectors/connectorsSlice'; +import { connectorDescription } from '../connectors/i18n'; import { useLoaderState } from '../hooks'; import type { AvailableToolType } from '../modals/types'; import AddToolModal from '../modals/AddToolModal'; @@ -178,7 +180,9 @@ export default function Tools() { variant: 'default', }); } - if (tool.name === 'mcp_tool') { + // A connected server reconnects on its connector page, like any other + // connection; only an MCP tool without one keeps the server form. + if (tool.name === 'mcp_tool' && !connectionOf(tool)) { options.splice(1, 0, { icon: RefreshCw, label: t('settings.tools.reconnect'), @@ -402,123 +406,143 @@ export default function Tools() { ) : (
- {filtered.map((tool, index) => ( - - {!tool.default && ( - - )} -
-
- { + const connection = connectionOf(tool); + const connector = connection + ? catalog.find( + (c) => c.key === connection.connector_key, + ) + : undefined; + // A catalog service reads as the catalog describes it; + // a custom server keeps the description it came with. + const description = + connector && connector.publisher !== 'custom' + ? connectorDescription(t, connector) + : tool.description; + return ( + + {!tool.default && ( + - {tool.default && ( - - {t('settings.tools.builtIn')} - - )} - {tool.name === 'mcp_tool' && - mcpStatuses[tool.id] && ( - - {mcpStatuses[tool.id] === 'connected' - ? t('settings.tools.authStatus.connected') - : mcpStatuses[tool.id] === 'needs_auth' - ? t('settings.tools.authStatus.needsAuth') - : t( - 'settings.tools.authStatus.configured', - )} + )} +
+
+ + {tool.default && ( + + {t('settings.tools.builtIn')} )} - {tool.ownership === 'team' && ( - - - )} + {connectionNeedsSignIn(connection) ? ( + + {t('settings.connectors.health.signInAgain')} + + ) : !tool.status ? ( + + {t('settings.tools.off')} + + ) : null} + {tool.name === 'mcp_tool' && + !connection && + mcpStatuses[tool.id] && ( + + {mcpStatuses[tool.id] === 'connected' + ? t('settings.tools.authStatus.connected') + : mcpStatuses[tool.id] === 'needs_auth' + ? t( + 'settings.tools.authStatus.needsAuth', + ) + : t( + 'settings.tools.authStatus.configured', + )} + + )} + {tool.ownership === 'team' && ( + + + )} +
+
+ + {tool.customName || tool.displayName} + + + {description} + +
-
- - {tool.customName || tool.displayName} - - - {tool.description} - -
-
- {(() => { - const connection = tool.connection_id - ? connections.find( - (c) => c.id === tool.connection_id, - ) - : undefined; - if (!connection) return null; - // The same meta line as a Sources tile; mr-12 keeps - // it clear of the status switch. - return ( + {connection && ( + // The way to the connection's page, marked with + // the service it comes from. - - - - {t('settings.tools.viaConnection', { - name: connection.name, - interpolation: { escapeValue: false }, - })} - - + - ); - })()} -
- - updateToolStatus(tool.id, checked) - } - id={`toolToggle-${index}`} - aria-label={t('settings.tools.toggleToolAria', { - toolName: tool.customName || tool.displayName, - })} - /> -
- - ))} + )} + {/* A connected tool is turned on and off on its + connection's page, the one home for it. */} + {!connection && ( +
+ + updateToolStatus(tool.id, checked) + } + id={`toolToggle-${index}`} + aria-label={t('settings.tools.toggleToolAria', { + toolName: tool.customName || tool.displayName, + })} + /> +
+ )} + + ); + })}
); })()