One connect flow and one name from every entry point

- Add Source hands a connection tile to the connect wizard: an existing
  account goes straight to choosing what to sync, otherwise it connects
  one. The legacy per-service forms stay only for installs without
  connectors, so S3 no longer has two different forms.
- Add Tool lists services from the connector catalog first, MCP presets
  included, each with its connection state; a connected one opens its
  drawer, the rest start the wizard. Built-in tools follow.
- Service tools carry the connector's name everywhere ("Telegram", not
  "Telegram Bot"), and the composer groups custom tools (API tool, MCP
  servers) as Custom, like the agent builder.
- OpenAPI opens the API tool editor as an unsaved draft; the tool is
  created on its first save, so exploring the menu leaves nothing behind.
This commit is contained in:
arc53-machine committed 2026-09-28 21:49:01 +01:00
1 parent bfcc4a9773
commit 23229181f1
18 files changed
+434 -97

No files matched your search

+2
View File
@@ -224,6 +224,8 @@ class AvailableTools(Resource):
)):
continue
group, connector_key = "service", definition.key
# One name everywhere: the connector's, not the tool's own.
name = definition.name
elif tool_name in _CUSTOM_CONNECTOR_TOOLS:
group, connector_key = "custom", _CUSTOM_CONNECTOR_TOOLS[tool_name]
else:
+12 -5
View File
@@ -1580,11 +1580,15 @@ export default function MessageInput({
? connections.find((c) => c.id === tool.connection_id)
: undefined;
const anyConnectedTool = userTools.some((tool) => toolConnection(tool));
// Same groups as the agent builder: built in, one per service, then custom
// tools (an API tool, an MCP server with no connection).
const isCustomTool = (tool: UserToolType) =>
!toolConnection(tool) &&
(tool.name === 'api_tool' || tool.name === 'mcp_tool');
const toolRank = (tool: UserToolType) =>
toolConnection(tool) ? 1 : isCustomTool(tool) ? 2 : 0;
const toolItems: MultiSelectPopoverItem[] = [...userTools]
.sort(
(a, b) =>
Number(Boolean(toolConnection(a))) - Number(Boolean(toolConnection(b))),
)
.sort((a, b) => toolRank(a) - toolRank(b))
.map((tool) => {
const connection = toolConnection(tool);
return {
@@ -1596,7 +1600,10 @@ export default function MessageInput({
<ToolIcon name={tool.name} className="size-5" />
),
group: anyConnectedTool
? (connection?.name ?? t('settings.tools.groupBuiltIn'))
? (connection?.name ??
(isCustomTool(tool)
? t('agents.form.toolsPopup.groupCustom')
: t('settings.tools.groupBuiltIn')))
: undefined,
};
});
@@ -1,14 +1,10 @@
import { useCallback, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { useNavigate } from 'react-router-dom';
import connectorsService from '../api/services/connectorsService';
import userService from '../api/services/userService';
import MCPServerModal from '../modals/MCPServerModal';
import type { AvailableToolType } from '../modals/types';
import type { ActiveState } from '../models/misc';
import { showActionToast } from '../notifications/actionToastSlice';
import { selectToken } from '../preferences/preferenceSlice';
import type { AppDispatch } from '../store';
import ConnectWizard, { type WizardMode } from './ConnectWizard';
@@ -50,7 +46,6 @@ const isMcp = (connector: ConnectorDefinition) =>
export default function useConnectorLauncher({
onConnected,
}: { onConnected?: () => void } = {}) {
const { t } = useTranslation();
const dispatch = useDispatch<AppDispatch>();
const navigate = useNavigate();
const token = useSelector(selectToken);
@@ -96,37 +91,9 @@ export default function useConnectorLauncher({
return;
}
if (connector.key === 'custom_openapi') {
// An OpenAPI tool starts empty; its spec import is today's API Tool
// screen on the Tools page.
try {
const response = await userService.getAvailableTools(token);
const data = await response.json();
const tool = (data.data as AvailableToolType[] | undefined)?.find(
(candidate) => candidate.name === 'api_tool',
);
if (!tool) throw new Error('api_tool is not available');
const created = await userService.createTool(
{
name: tool.name,
displayName: tool.displayName,
description: tool.description,
config: {},
actions: tool.actions,
status: true,
},
token,
);
const body = await created.json();
if (!body?.id) throw new Error('create failed');
navigate('/settings/tools', { state: { openToolId: body.id } });
} catch {
dispatch(
showActionToast({
variant: 'destructive',
message: t('settings.connectors.createFailed'),
}),
);
}
// The API tool screen on the Tools page imports the spec; the tool
// is only created when it is saved there.
navigate('/settings/tools', { state: { newApiTool: true } });
return;
}
setActive({
@@ -136,7 +103,7 @@ export default function useConnectorLauncher({
connectionId: options.connectionId,
});
},
[dispatch, navigate, t, token],
[navigate],
);
const afterMcpSave = async () => {
+1 -1
View File
@@ -860,7 +860,7 @@
"toolIconTitle": "{{name}}-Symbol",
"groupBuiltIn": "Integriert",
"groupService": "Aus einem Dienst",
"browseConnectors": "Suchst du Notion, Linear oder einen anderen Dienst? Konnektoren durchsuchen",
"browseConnectors": "Alle Konnektoren ansehen",
"viaConnection": "über {{name}}"
},
"devices": {
+1 -1
View File
@@ -866,7 +866,7 @@
"toolIconTitle": "{{name}} icon",
"groupBuiltIn": "Built in",
"groupService": "From a service",
"browseConnectors": "Looking for Notion, Linear or another service? Browse connectors",
"browseConnectors": "Browse all connectors",
"viaConnection": "via {{name}}"
},
"devices": {
+1 -1
View File
@@ -860,7 +860,7 @@
"toolIconTitle": "Icono de {{name}}",
"groupBuiltIn": "Integradas",
"groupService": "De un servicio",
"browseConnectors": "¿Buscas Notion, Linear u otro servicio? Explora los conectores",
"browseConnectors": "Ver todos los conectores",
"viaConnection": "mediante {{name}}"
},
"devices": {
+1 -1
View File
@@ -851,7 +851,7 @@
"toolIconTitle": "{{name}}のアイコン",
"groupBuiltIn": "組み込み",
"groupService": "サービスから",
"browseConnectors": "Notion、Linear などのサービスをお探しですか?コネクタを見る",
"browseConnectors": "すべてのコネクタを見る",
"viaConnection": "{{name}} 経由"
},
"devices": {
+1 -1
View File
@@ -902,7 +902,7 @@
"toolIconTitle": "Значок {{name}}",
"groupBuiltIn": "Встроенные",
"groupService": "Из сервиса",
"browseConnectors": "Ищете Notion, Linear или другой сервис? Смотрите коннекторы",
"browseConnectors": "Все коннекторы",
"viaConnection": "через {{name}}"
},
"devices": {
+1 -1
View File
@@ -851,7 +851,7 @@
"toolIconTitle": "{{name}} 圖示",
"groupBuiltIn": "內建",
"groupService": "來自服務",
"browseConnectors": "在找 Notion、Linear 或其他服務嗎?瀏覽連接器",
"browseConnectors": "瀏覽所有連接器",
"viaConnection": "透過 {{name}}"
},
"devices": {
+1 -1
View File
@@ -851,7 +851,7 @@
"toolIconTitle": "{{name}} 图标",
"groupBuiltIn": "内置",
"groupService": "来自服务",
"browseConnectors": "在找 Notion、Linear 或其他服务?浏览连接器",
"browseConnectors": "浏览所有连接器",
"viaConnection": "通过 {{name}}"
},
"devices": {
+177
View File
@@ -0,0 +1,177 @@
import { configureStore } from '@reduxjs/toolkit';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { Provider } from 'react-redux';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock('../api/services/userService', () => ({
default: {
getAvailableTools: vi.fn(async () => ({
json: async () => ({
data: [
{
name: 'memory',
displayName: 'Memory',
description: 'Remembers',
configRequirements: {},
actions: [],
group: 'built_in',
},
],
}),
})),
},
}));
vi.mock('../api/services/connectorsService', () => ({
default: {
getCatalog: vi.fn(async () => ({
success: true,
connectors: server.catalog,
})),
listConnections: vi.fn(async () => ({ success: true, connections: [] })),
},
}));
const launch = vi.hoisted(() => vi.fn());
vi.mock('../connectors/useConnectorLauncher', () => ({
default: () => ({ launch, modals: null }),
}));
import connectorsReducer from '../connectors/connectorsSlice';
import AddToolModal from './AddToolModal';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const entry = (overrides: Record<string, unknown>) => ({
description: '',
category: 'dev',
publisher: 'built_in',
capabilities: ['read', 'write'],
available: true,
connection_count: 0,
connected_count: 0,
state: 'available',
...overrides,
});
const server = vi.hoisted(() => ({ catalog: [] as unknown[] }));
describe('AddToolModal', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
launch.mockClear();
server.catalog = [
entry({
key: 'mcp:notion',
name: 'Notion',
icon: 'notion',
publisher: 'preset',
}),
entry({
key: 'telegram',
name: 'Telegram',
icon: 'tool_telegram',
connection_count: 1,
connected_count: 1,
state: 'connected',
}),
entry({
key: 'google_drive',
name: 'Google Drive',
icon: 'drive',
capabilities: ['sync'],
}),
entry({
key: 'custom_mcp',
name: 'MCP server',
icon: 'mcp',
publisher: 'custom',
}),
];
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
const render = async () => {
const store = configureStore({
reducer: {
connectors: connectorsReducer,
preference: (state = { token: null }) => state,
},
});
const setModalState = vi.fn();
await act(async () => {
root.render(
<Provider store={store}>
<MemoryRouter initialEntries={['/settings/tools']}>
<Routes>
<Route
path="/settings/tools"
element={
<AddToolModal
message=""
modalState="ACTIVE"
setModalState={setModalState}
getUserTools={vi.fn()}
onToolAdded={vi.fn()}
/>
}
/>
<Route path="/settings/connectors" element={<div>DRAWER</div>} />
</Routes>
</MemoryRouter>
</Provider>,
);
});
// The loader holds its skeleton for 250 ms after the tools arrive.
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 300));
});
return setModalState;
};
const service = (key: string) =>
document.body.querySelector<HTMLButtonElement>(
`[data-testid="add-tool-service-${key}"]`,
);
it('lists services, presets included, before built-in tools', async () => {
await render();
const text = document.body.textContent ?? '';
const services = text.indexOf('settings.tools.groupService');
expect(services).toBeGreaterThan(-1);
expect(services).toBeLessThan(text.indexOf('settings.tools.groupBuiltIn'));
expect(service('mcp:notion')).not.toBeNull();
// Sync-only and custom connectors are not tools here.
expect(service('google_drive')).toBeNull();
expect(service('custom_mcp')).toBeNull();
});
it('connects a service that is not connected yet', async () => {
await render();
await act(async () => service('mcp:notion')!.click());
expect(launch).toHaveBeenCalledWith(
expect.objectContaining({ key: 'mcp:notion' }),
);
});
it('opens a connected service to manage its tools', async () => {
await render();
await act(async () => service('telegram')!.click());
expect(launch).not.toHaveBeenCalled();
expect(document.body.textContent).toContain('DRAWER');
});
});
+85 -16
View File
@@ -8,7 +8,12 @@ import userService from '../api/services/userService';
import SkeletonLoader from '../components/SkeletonLoader';
import ToolIcon from '../components/ToolIcon';
import { Button } from '../components/ui/button';
import { Card, CardDescription, CardTitle } from '../components/ui/card';
import {
Card,
CardDescription,
CardFooter,
CardTitle,
} from '../components/ui/card';
import { Modal, ModalActions } from '../components/ui/modal';
import { SectionHeader } from '../components/ui/section-header';
import { useLoaderState } from '../hooks';
@@ -16,6 +21,10 @@ import {
loadConnectors,
selectConnectorCatalog,
} from '../connectors/connectorsSlice';
import { ConnectorStateBadge } from '../connectors/ConnectorCard';
import ConnectorIcon from '../connectors/ConnectorIcon';
import { connectorDescription, connectorName } from '../connectors/i18n';
import type { ConnectorDefinition } from '../connectors/types';
import useConnectorLauncher from '../connectors/useConnectorLauncher';
import PairDeviceModal from '../settings/PairDeviceModal';
import type { AppDispatch } from '../store';
@@ -75,6 +84,30 @@ export default function AddToolModal({
});
};
// Services come from the connector catalog (tool connectors and MCP
// presets): they are added by connecting the service, first in the list.
const services = catalog.filter(
(connector) =>
connector.publisher !== 'custom' &&
connector.available &&
connector.capabilities.some((c) => c === 'read' || c === 'write'),
);
const builtIn = availableTools.filter(
(tool) => (tool.group ?? 'built_in') === 'built_in',
);
const openService = (connector: ConnectorDefinition) => {
setModalState('INACTIVE');
// Already connected: its tools exist; open its drawer to manage them.
if (connector.connection_count > 0) {
navigate(
`/settings/connectors?connector=${encodeURIComponent(connector.key)}`,
);
return;
}
launch(connector);
};
const handleAddTool = (tool: AvailableToolType) => {
// ``remote_device`` is created server-side via the pairing redeem
// endpoint, not the standard create_tool path.
@@ -171,24 +204,60 @@ export default function AddToolModal({
<SkeletonLoader component="addToolCards" count={6} />
</div>
) : (
(['built_in', 'service'] as const).map((group) => {
const tools = availableTools.filter(
(tool) => (tool.group ?? 'built_in') === group,
);
if (tools.length === 0) return null;
return (
<section key={group} className="flex flex-col gap-3">
<>
{services.length > 0 && (
<section className="flex flex-col gap-3">
<SectionHeader
as="h3"
size="sm"
title={
group === 'built_in'
? t('settings.tools.groupBuiltIn')
: t('settings.tools.groupService')
}
title={t('settings.tools.groupService')}
/>
<div className="grid auto-rows-fr grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{tools.map((tool) => (
{services.map((connector) => (
<Card
asChild
key={connector.key}
variant="outline"
padding="lg"
interactive
className="h-44 w-full"
>
<button
type="button"
onClick={() => openService(connector)}
data-testid={`add-tool-service-${connector.key}`}
>
<ConnectorIcon
icon={connector.icon}
className="size-6"
/>
<CardTitle
title={connectorName(t, connector)}
className="truncate"
>
{connectorName(t, connector)}
</CardTitle>
<CardDescription size="xs" className="line-clamp-2">
{connectorDescription(t, connector)}
</CardDescription>
<CardFooter>
<ConnectorStateBadge connector={connector} />
</CardFooter>
</button>
</Card>
))}
</div>
</section>
)}
{builtIn.length > 0 && (
<section className="flex flex-col gap-3">
<SectionHeader
as="h3"
size="sm"
title={t('settings.tools.groupBuiltIn')}
/>
<div className="grid auto-rows-fr grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{builtIn.map((tool) => (
<Card
asChild
key={tool.name}
@@ -225,8 +294,8 @@ export default function AddToolModal({
))}
</div>
</section>
);
})
)}
</>
)}
</div>
</Modal>
+12
View File
@@ -19,9 +19,11 @@ vi.mock('../modals/AddActionModal', () => ({ default: () => null }));
vi.mock('../modals/ImportSpecModal', () => ({ default: () => null }));
const updateTool = vi.fn();
const createTool = vi.fn();
vi.mock('../api/services/userService', () => ({
default: {
updateTool: (...args: unknown[]) => updateTool(...args),
createTool: (...args: unknown[]) => createTool(...args),
deleteTool: () => Promise.resolve(),
},
}));
@@ -393,4 +395,14 @@ describe('ToolConfig', () => {
expect(cancel?.dataset.size).toBe('sm');
expect(cancel?.dataset.shape).toBe('pill');
});
it('creates a draft OpenAPI tool on its first save, not before', async () => {
createTool.mockResolvedValue({ ok: true });
updateTool.mockClear();
await render({ ...apiTool, id: '' } as APIToolType);
expect(createTool).not.toHaveBeenCalled();
await act(async () => buttonByText('settings.tools.save')!.click());
expect(updateTool).not.toHaveBeenCalled();
expect(createTool.mock.calls[0][0]).toMatchObject({ name: 'api_tool' });
});
});
+29 -27
View File
@@ -245,6 +245,26 @@ export default function ToolConfig({
});
};
// Saves the tool; a draft without an id (a new OpenAPI tool) is created
// on its first save, so leaving without saving leaves nothing behind.
const persistTool = async (configToSave: Record<string, unknown>) => {
const payload = {
name: tool.name,
displayName: tool.displayName,
customName: customName,
description: tool.description,
config: configToSave,
actions: 'actions' in tool ? tool.actions : [],
status: tool.status,
};
if (tool.id) {
await userService.updateTool({ id: tool.id, ...payload }, token);
return;
}
const response = await userService.createTool(payload, token);
if (!response.ok) throw new Error('create failed');
};
const handleSaveChanges = async () => {
if (!validateConfig()) return;
const configToSave = buildConfigToSave();
@@ -253,19 +273,7 @@ export default function ToolConfig({
setSaveError('');
try {
await userService.updateTool(
{
id: tool.id,
name: tool.name,
displayName: tool.displayName,
customName: customName,
description: tool.description,
config: configToSave,
actions: 'actions' in tool ? tool.actions : [],
status: tool.status,
},
token,
);
await persistTool(configToSave);
setInitialState({
customName,
configValues: { ...configValues },
@@ -282,6 +290,11 @@ export default function ToolConfig({
};
const handleDelete = () => {
// A draft (a new OpenAPI tool not saved yet) has nothing to delete.
if (!tool.id) {
handleGoBack();
return;
}
userService.deleteTool({ id: tool.id }, token).then(() => {
handleGoBack();
});
@@ -362,7 +375,8 @@ export default function ToolConfig({
size="sm"
shape="pill"
onClick={handleSaveChanges}
disabled={!hasUnsavedChanges}
// A draft (no id yet) is saved to create it.
disabled={!hasUnsavedChanges && !!tool.id}
loading={saving}
>
{t('settings.tools.save')}
@@ -768,19 +782,7 @@ export default function ToolConfig({
setSaveError('');
try {
await userService.updateTool(
{
id: tool.id,
name: tool.name,
displayName: tool.displayName,
customName: customName,
description: tool.description,
config: configToSave,
actions: 'actions' in tool ? tool.actions : [],
status: tool.status,
},
token,
);
await persistTool(configToSave);
setShowUnsavedModal(false);
handleGoBack();
} catch {
+33 -4
View File
@@ -28,6 +28,7 @@ import {
selectConnectorCatalog,
} from '../connectors/connectorsSlice';
import { useLoaderState } from '../hooks';
import type { AvailableToolType } from '../modals/types';
import AddToolModal from '../modals/AddToolModal';
import ConfirmationModal from '../modals/ConfirmationModal';
import MCPServerModal from '../modals/MCPServerModal';
@@ -283,15 +284,43 @@ export default function Tools() {
dispatch(loadConnectors({ token }));
}, []);
// The Connectors page creates an OpenAPI tool and sends its id here so
// the spec import opens straight away.
const openToolId = (location.state as { openToolId?: string } | null)
?.openToolId;
// The Connectors page opens a new OpenAPI tool here as an unsaved draft:
// the spec import opens straight away and the tool is created on save.
const routeState = location.state as {
openToolId?: string;
newApiTool?: boolean;
} | null;
const openToolId = routeState?.openToolId;
const newApiTool = routeState?.newApiTool;
React.useEffect(() => {
if (!openToolId) return;
handleToolAdded(openToolId);
navigate(location.pathname, { replace: true, state: null });
}, [openToolId]);
React.useEffect(() => {
if (!newApiTool) return;
navigate(location.pathname, { replace: true, state: null });
userService
.getAvailableTools(token)
.then((res) => res.json())
.then((data) => {
const template = (data.data as AvailableToolType[] | undefined)?.find(
(candidate) => candidate.name === 'api_tool',
);
if (!template) return;
setSelectedTool({
id: '',
name: template.name,
displayName: template.displayName,
customName: '',
description: template.description,
config: {},
actions: template.actions,
status: true,
} as unknown as UserToolType);
})
.catch(() => undefined);
}, [newApiTool]);
return (
<div>
{selectedTool ? (
+33
View File
@@ -38,6 +38,11 @@ vi.mock('../api/services/userService', () => ({
},
}));
const launch = vi.hoisted(() => vi.fn());
vi.mock('../connectors/useConnectorLauncher', () => ({
default: () => ({ launch, modals: null }),
}));
import Upload from './Upload';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
@@ -190,6 +195,34 @@ describe('Upload source-type tiles', () => {
connectorsState.connections = [];
});
it('hands a connection tile over to the connect wizard', async () => {
launch.mockClear();
const s3 = {
key: 's3',
icon: 's3',
sync_ingestor: 's3',
auth_kind: 'api_key',
available: true,
missing_settings: [],
};
connectorsState.catalog = [s3];
connectorsState.connections = [
{ id: 'k1', connector_key: 's3', status: 'connected' },
];
await render();
const tile = tiles().find((t) =>
t.textContent?.includes('ingestors.s3.label'),
)!;
await act(async () => tile.click());
// An existing account goes straight to choosing what to sync.
expect(launch).toHaveBeenCalledWith(s3, {
mode: 'sync',
connectionId: 'k1',
});
connectorsState.catalog = [];
connectorsState.connections = [];
});
it('picks the saved account once connections finish loading', async () => {
connectorsState.catalog = [
{
+27 -1
View File
@@ -30,6 +30,7 @@ import { OptionCard } from '../components/ui/option-card';
import { SectionHeader } from '../components/ui/section-header';
import ConnectorIcon from '../connectors/ConnectorIcon';
import ConnectorSetupNotice from '../connectors/ConnectorSetupNotice';
import useConnectorLauncher from '../connectors/useConnectorLauncher';
import { formatCount } from '../utils/dateTimeUtils';
import {
loadConnectors,
@@ -109,6 +110,12 @@ function Upload({
const connections = useSelector(selectConnections);
const connectorsLoaded = useSelector(selectConnectorsLoaded);
const connectorsEnabled = useSelector(selectConnectorsEnabled);
// A connection tile hands over to the connect wizard, the one flow every
// entry point uses; this modal steps aside and closes with it.
const [handedOver, setHandedOver] = useState(false);
const { launch, modals: connectModals } = useConnectorLauncher({
onConnected: () => close(),
});
const [files, setfiles] = useState<File[]>(receivedFile);
// Names of the files the last drop turned away (over the size limit or of
@@ -1263,7 +1270,24 @@ function Upload({
`modals.uploadDoc.ingestors.${option.value}.label`,
)}
description={connectionTileState(option.value)}
onClick={() => handleIngestorTypeChange(option.value)}
onClick={() => {
if (connectorsEnabled && connector?.available) {
const account = connections.find(
(c) =>
c.connector_key === connector.key &&
c.status === 'connected',
);
setHandedOver(true);
launch(
connector,
account
? { mode: 'sync', connectionId: account.id }
: {},
);
return;
}
handleIngestorTypeChange(option.value);
}}
/>
);
})}
@@ -1278,6 +1302,8 @@ function Upload({
selectedConnector && needsSetup ? (
<ConnectorSetupNotice connector={selectedConnector} />
) : null;
if (handedOver) return <>{connectModals}</>;
return (
<Modal
open={true}
+13
View File
@@ -221,3 +221,16 @@ class TestAvailableTools:
names = {t["name"] for t in AvailableTools().get().get_json()["data"]}
assert "telegram" not in names
assert "brave" in names
def test_service_tools_use_the_connector_name(self, app, pg_conn):
"""One name everywhere: "Telegram", not the tool's "Telegram Bot"."""
from docsgpt.api.user.tools.routes import AvailableTools
with _db(pg_conn), app.test_request_context("/api/available_tools"):
from flask import request
request.decoded_token = {"sub": "alice"}
tools = {t["name"]: t for t in AvailableTools().get().get_json()["data"]}
assert tools["telegram"]["displayName"] == "Telegram"
assert tools["ntfy"]["displayName"] == "ntfy"
assert tools["postgres"]["displayName"] == "PostgreSQL"