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.
This commit is contained in:
arc53-machine committed 2026-09-29 00:54:07 +01:00
1 parent cc7b07817f
commit 97e37a7e37
3 files changed
+216 -117

No files matched your search

+7 -4
View File
@@ -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) =>
+73 -1
View File
@@ -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<HTMLElement>('h2'))
.find((h) => h.textContent === title)!
.closest<HTMLElement>('[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();
});
});
+136 -112
View File
@@ -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() {
<EmptyState title={t('settings.tools.noToolsFound')} />
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{filtered.map((tool, index) => (
<Card
key={index}
variant="filled"
padding="lg"
className="relative h-52 justify-between overflow-hidden"
>
{!tool.default && (
<ActionMenu
options={getMenuOptions(tool)}
triggerLabel={t('settings.tools.settingsIconAlt')}
className="absolute top-3 right-3 z-10"
/>
)}
<div className="w-full">
<div className="flex w-full items-center gap-2 px-1">
<ToolIcon
name={tool.name}
title={t('settings.tools.toolIconTitle', {
name: tool.displayName,
})}
className="size-6"
{filtered.map((tool, index) => {
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 (
<Card
key={index}
variant="filled"
padding="lg"
className="relative h-52 justify-between overflow-hidden"
>
{!tool.default && (
<ActionMenu
options={getMenuOptions(tool)}
triggerLabel={t('settings.tools.settingsIconAlt')}
className="absolute top-3 right-3 z-10"
/>
{tool.default && (
<Badge variant="neutral">
{t('settings.tools.builtIn')}
</Badge>
)}
{tool.name === 'mcp_tool' &&
mcpStatuses[tool.id] && (
<Badge
variant={
mcpStatuses[tool.id] === 'connected'
? 'success'
: mcpStatuses[tool.id] === 'needs_auth'
? 'warning'
: 'neutral'
}
>
{mcpStatuses[tool.id] === 'connected'
? t('settings.tools.authStatus.connected')
: mcpStatuses[tool.id] === 'needs_auth'
? t('settings.tools.authStatus.needsAuth')
: t(
'settings.tools.authStatus.configured',
)}
)}
<div className="w-full">
<div className="flex w-full items-center gap-2 px-1">
<ToolIcon
name={tool.name}
title={t('settings.tools.toolIconTitle', {
name: tool.displayName,
})}
className="size-6"
/>
{tool.default && (
<Badge variant="neutral">
{t('settings.tools.builtIn')}
</Badge>
)}
{tool.ownership === 'team' && (
<Badge variant="neutral">
<Users className="size-3" aria-hidden="true" />
{tool.team_access === 'editor'
? t('teamAccess.editor')
: t('teamAccess.viewer')}
</Badge>
)}
{connectionNeedsSignIn(connection) ? (
<Badge variant="warning">
{t('settings.connectors.health.signInAgain')}
</Badge>
) : !tool.status ? (
<Badge variant="neutral">
{t('settings.tools.off')}
</Badge>
) : null}
{tool.name === 'mcp_tool' &&
!connection &&
mcpStatuses[tool.id] && (
<Badge
variant={
mcpStatuses[tool.id] === 'connected'
? 'success'
: mcpStatuses[tool.id] === 'needs_auth'
? 'warning'
: 'neutral'
}
>
{mcpStatuses[tool.id] === 'connected'
? t('settings.tools.authStatus.connected')
: mcpStatuses[tool.id] === 'needs_auth'
? t(
'settings.tools.authStatus.needsAuth',
)
: t(
'settings.tools.authStatus.configured',
)}
</Badge>
)}
{tool.ownership === 'team' && (
<Badge variant="neutral">
<Users
className="size-3"
aria-hidden="true"
/>
{tool.team_access === 'editor'
? t('teamAccess.editor')
: t('teamAccess.viewer')}
</Badge>
)}
</div>
<div className="mt-[9px] px-1">
<CardTitle
as="h2"
title={tool.customName || tool.displayName}
className="truncate capitalize"
>
{tool.customName || tool.displayName}
</CardTitle>
<CardDescription
size="xs"
className="mt-1 line-clamp-4 max-h-24 overflow-hidden break-words"
title={description}
>
{description}
</CardDescription>
</div>
</div>
<div className="mt-[9px] px-1">
<CardTitle
as="h2"
title={tool.customName || tool.displayName}
className="truncate capitalize"
>
{tool.customName || tool.displayName}
</CardTitle>
<CardDescription
size="xs"
className="mt-1 line-clamp-4 max-h-24 overflow-hidden break-words"
title={tool.description}
>
{tool.description}
</CardDescription>
</div>
</div>
{(() => {
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.
<CardFooter>
<span className="mr-12 flex min-w-0 items-center gap-2">
<ConnectorIcon
icon={connection.icon}
className="size-3.5 shrink-0"
/>
<span
className="truncate"
title={connection.account_label}
>
{t('settings.tools.viaConnection', {
name: connection.name,
interpolation: { escapeValue: false },
})}
</span>
</span>
<Button
type="button"
variant="outline"
size="sm"
shape="pill"
onClick={() => handleSettingsClick(tool)}
>
<ConnectorIcon icon={connection.icon} />
{t('settings.connectors.manageConnection')}
</Button>
</CardFooter>
);
})()}
<div className="absolute right-4 bottom-4">
<Switch
checked={tool.status}
onCheckedChange={(checked) =>
updateToolStatus(tool.id, checked)
}
id={`toolToggle-${index}`}
aria-label={t('settings.tools.toggleToolAria', {
toolName: tool.customName || tool.displayName,
})}
/>
</div>
</Card>
))}
)}
{/* A connected tool is turned on and off on its
connection's page, the one home for it. */}
{!connection && (
<div className="absolute right-4 bottom-4">
<Switch
checked={tool.status}
onCheckedChange={(checked) =>
updateToolStatus(tool.id, checked)
}
id={`toolToggle-${index}`}
aria-label={t('settings.tools.toggleToolAria', {
toolName: tool.customName || tool.displayName,
})}
/>
</div>
)}
</Card>
);
})}
</div>
);
})()