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, + })} + /> +
+ )} + + ); + })}
); })()