feat(mcp): search + keyboard navigation for installed MCP servers (#2639)

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Aashir Athar
2026-05-28 20:17:21 -07:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 5bd47bbcf1
commit 55b1e6a30d
19 changed files with 614 additions and 43 deletions
@@ -183,8 +183,9 @@ describe('InstalledServerList', () => {
onBrowseCatalog={() => {}}
/>
);
// The status dot has title="error"
expect(screen.getByTitle('error')).toBeInTheDocument();
// The status dot title is the i18n'd label ('Error' in English) —
// sourced from `channels.status.error` per `STATUS_I18N_KEYS`.
expect(screen.getByTitle('Error')).toBeInTheDocument();
});
it('falls back to disconnected status when no matching status entry', () => {
@@ -197,7 +198,7 @@ describe('InstalledServerList', () => {
onBrowseCatalog={() => {}}
/>
);
expect(screen.getByTitle('disconnected')).toBeInTheDocument();
expect(screen.getByTitle('Disconnected')).toBeInTheDocument();
});
// -----------------------------------------------------------------------
@@ -234,4 +235,287 @@ describe('InstalledServerList', () => {
fireEvent.click(screen.getByRole('button', { name: 'Browse catalog' }));
expect(onBrowse).toHaveBeenCalledTimes(1);
});
// -----------------------------------------------------------------------
// Filter behaviour (the new search/filter feature)
// -----------------------------------------------------------------------
it('shows all servers when filter is the empty string', () => {
render(
<InstalledServerList
servers={[SERVER_1, SERVER_2]}
statuses={[]}
selectedId={null}
onSelect={() => {}}
onBrowseCatalog={() => {}}
filter=""
/>
);
expect(screen.getByText('File Server')).toBeInTheDocument();
expect(screen.getByText('DB Server')).toBeInTheDocument();
});
it('filters by display_name case-insensitively', () => {
render(
<InstalledServerList
servers={[SERVER_1, SERVER_2]}
statuses={[]}
selectedId={null}
onSelect={() => {}}
onBrowseCatalog={() => {}}
filter="FILE"
/>
);
expect(screen.getByText('File Server')).toBeInTheDocument();
expect(screen.queryByText('DB Server')).not.toBeInTheDocument();
});
it('filters by qualified_name', () => {
render(
<InstalledServerList
servers={[SERVER_1, SERVER_2]}
statuses={[]}
selectedId={null}
onSelect={() => {}}
onBrowseCatalog={() => {}}
filter="db-server"
/>
);
// SERVER_2 has qualified_name 'acme/db-server' — matched
expect(screen.getByText('DB Server')).toBeInTheDocument();
expect(screen.queryByText('File Server')).not.toBeInTheDocument();
});
it('filters by description', () => {
render(
<InstalledServerList
servers={[SERVER_1, SERVER_2]}
statuses={[]}
selectedId={null}
onSelect={() => {}}
onBrowseCatalog={() => {}}
filter="reads files"
/>
);
// SERVER_1 has description 'Reads files' — matched
expect(screen.getByText('File Server')).toBeInTheDocument();
expect(screen.queryByText('DB Server')).not.toBeInTheDocument();
});
it('treats undefined description as empty (no false match) without crashing', () => {
render(
<InstalledServerList
servers={[SERVER_2]} // description: undefined
statuses={[]}
selectedId={null}
onSelect={() => {}}
onBrowseCatalog={() => {}}
filter="undefined"
/>
);
// 'undefined' must not match the absent description literally — assertion
// here is that the filter logic doesn't blow up and the no-match path runs.
expect(screen.queryByText('DB Server')).not.toBeInTheDocument();
});
it('trims surrounding whitespace from the filter', () => {
render(
<InstalledServerList
servers={[SERVER_1, SERVER_2]}
statuses={[]}
selectedId={null}
onSelect={() => {}}
onBrowseCatalog={() => {}}
filter=" File "
/>
);
expect(screen.getByText('File Server')).toBeInTheDocument();
expect(screen.queryByText('DB Server')).not.toBeInTheDocument();
});
it('shows "no matches" message including the query when filter matches nothing', () => {
render(
<InstalledServerList
servers={[SERVER_1, SERVER_2]}
statuses={[]}
selectedId={null}
onSelect={() => {}}
onBrowseCatalog={() => {}}
filter="zzz-nope"
/>
);
expect(screen.getByText('No servers match "zzz-nope".')).toBeInTheDocument();
});
it('shows "X of Y servers" count via an aria-live region when filtering', () => {
render(
<InstalledServerList
servers={[SERVER_1, SERVER_2]}
statuses={[]}
selectedId={null}
onSelect={() => {}}
onBrowseCatalog={() => {}}
filter="File"
/>
);
// `status` is NOT a "name from content" role per WAI-ARIA, so the
// accessible name doesn't come from text. Query by text and then
// verify the live-region attributes on the same element.
const status = screen.getByText('1 of 2 servers');
expect(status).toHaveAttribute('role', 'status');
expect(status).toHaveAttribute('aria-live', 'polite');
});
it('hides the count when filter is empty', () => {
render(
<InstalledServerList
servers={[SERVER_1, SERVER_2]}
statuses={[]}
selectedId={null}
onSelect={() => {}}
onBrowseCatalog={() => {}}
filter=""
/>
);
expect(screen.queryByText(/of \d+ servers/)).not.toBeInTheDocument();
});
it('hides the count when filter is only whitespace', () => {
render(
<InstalledServerList
servers={[SERVER_1, SERVER_2]}
statuses={[]}
selectedId={null}
onSelect={() => {}}
onBrowseCatalog={() => {}}
filter=" "
/>
);
expect(screen.queryByText(/of \d+ servers/)).not.toBeInTheDocument();
});
it('keeps the original empty state (not the filtered no-match) when there are zero servers', () => {
render(
<InstalledServerList
servers={[]}
statuses={[]}
selectedId={null}
onSelect={() => {}}
onBrowseCatalog={() => {}}
filter="anything"
/>
);
expect(screen.getByText('No MCP servers installed yet.')).toBeInTheDocument();
expect(screen.queryByText(/No servers match/)).not.toBeInTheDocument();
});
// -----------------------------------------------------------------------
// Keyboard navigation (ArrowUp / ArrowDown across server buttons)
// -----------------------------------------------------------------------
it('moves focus to the next server on ArrowDown', () => {
render(
<InstalledServerList
servers={[SERVER_1, SERVER_2]}
statuses={[]}
selectedId={null}
onSelect={() => {}}
onBrowseCatalog={() => {}}
/>
);
const first = screen.getByRole('button', { name: /File Server/i });
const second = screen.getByRole('button', { name: /DB Server/i });
first.focus();
expect(first).toHaveFocus();
fireEvent.keyDown(first, { key: 'ArrowDown' });
expect(second).toHaveFocus();
});
it('moves focus to the previous server on ArrowUp', () => {
render(
<InstalledServerList
servers={[SERVER_1, SERVER_2]}
statuses={[]}
selectedId={null}
onSelect={() => {}}
onBrowseCatalog={() => {}}
/>
);
const first = screen.getByRole('button', { name: /File Server/i });
const second = screen.getByRole('button', { name: /DB Server/i });
second.focus();
fireEvent.keyDown(second, { key: 'ArrowUp' });
expect(first).toHaveFocus();
});
it('clamps focus at the last server on ArrowDown', () => {
render(
<InstalledServerList
servers={[SERVER_1, SERVER_2]}
statuses={[]}
selectedId={null}
onSelect={() => {}}
onBrowseCatalog={() => {}}
/>
);
const second = screen.getByRole('button', { name: /DB Server/i });
second.focus();
fireEvent.keyDown(second, { key: 'ArrowDown' });
// No wrap-around.
expect(second).toHaveFocus();
});
it('clamps focus at the first server on ArrowUp', () => {
render(
<InstalledServerList
servers={[SERVER_1, SERVER_2]}
statuses={[]}
selectedId={null}
onSelect={() => {}}
onBrowseCatalog={() => {}}
/>
);
const first = screen.getByRole('button', { name: /File Server/i });
first.focus();
fireEvent.keyDown(first, { key: 'ArrowUp' });
expect(first).toHaveFocus();
});
it('does not move focus or preventDefault for unrelated keys', () => {
render(
<InstalledServerList
servers={[SERVER_1, SERVER_2]}
statuses={[]}
selectedId={null}
onSelect={() => {}}
onBrowseCatalog={() => {}}
/>
);
const first = screen.getByRole('button', { name: /File Server/i });
first.focus();
const event = fireEvent.keyDown(first, { key: 'a' });
// The listener should ignore unrelated keys; focus stays put.
expect(first).toHaveFocus();
// fireEvent returns false if preventDefault was called — verify it wasn't.
expect(event).toBe(true);
});
it('arrow keys traverse only the visible (filtered) items', () => {
render(
<InstalledServerList
servers={[SERVER_1, SERVER_2]}
statuses={[]}
selectedId={null}
onSelect={() => {}}
onBrowseCatalog={() => {}}
filter="File"
/>
);
const visible = screen.getByRole('button', { name: /File Server/i });
visible.focus();
// Only one filtered item → ArrowDown should clamp (single visible)
fireEvent.keyDown(visible, { key: 'ArrowDown' });
expect(visible).toHaveFocus();
expect(screen.queryByRole('button', { name: /DB Server/i })).not.toBeInTheDocument();
});
});
@@ -1,6 +1,16 @@
/**
* List of installed MCP servers with status dot, name, and tool count.
*
* Supports an optional `filter` prop that case-insensitively matches
* against `display_name`, `qualified_name`, and `description`. When
* filtering is active the list shows a "X of Y servers" count via a
* `role="status"` live region so assistive tech announces the new
* total as the user types. ArrowUp / ArrowDown move focus between
* server buttons (clamped at the edges); Enter/Space activate via
* the underlying `<button>` semantics.
*/
import { type KeyboardEvent as ReactKeyboardEvent, useMemo, useRef } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import type { ConnStatus, InstalledServer, ServerStatus } from './types';
@@ -10,6 +20,8 @@ interface InstalledServerListProps {
selectedId: string | null;
onSelect: (serverId: string) => void;
onBrowseCatalog: () => void;
/** Optional case-insensitive filter applied to display_name / qualified_name / description. */
filter?: string;
}
const STATUS_DOT: Record<ServerStatus, string> = {
@@ -19,15 +31,57 @@ const STATUS_DOT: Record<ServerStatus, string> = {
error: 'bg-coral-500',
};
// i18n keys for the per-status tooltip on the status dot. Reuses the
// existing `channels.status.*` namespace (already shipped as the canonical
// status vocabulary by McpStatusBadge) so we don't fork translations for
// the same four words.
const STATUS_I18N_KEYS: Record<ServerStatus, string> = {
connected: 'channels.status.connected',
connecting: 'channels.status.connecting',
disconnected: 'channels.status.disconnected',
error: 'channels.status.error',
};
const InstalledServerList = ({
servers,
statuses,
selectedId,
onSelect,
onBrowseCatalog,
filter = '',
}: InstalledServerListProps) => {
const { t } = useT();
const statusMap = new Map((statuses ?? []).map(s => [s.server_id, s]));
const listRef = useRef<HTMLUListElement>(null);
const statusMap = useMemo(() => new Map((statuses ?? []).map(s => [s.server_id, s])), [statuses]);
const trimmedFilter = filter.trim().toLowerCase();
const isFiltering = trimmedFilter.length > 0;
const filteredServers = useMemo(() => {
if (!trimmedFilter) return servers;
return servers.filter(s => {
const haystack = [s.display_name, s.qualified_name, s.description ?? '']
.join(' ')
.toLowerCase();
return haystack.includes(trimmedFilter);
});
}, [servers, trimmedFilter]);
const handleItemKeyDown = (event: ReactKeyboardEvent<HTMLButtonElement>) => {
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
const root = listRef.current;
if (!root) return;
const buttons = Array.from(root.querySelectorAll<HTMLButtonElement>('button[data-server-id]'));
const currentIdx = buttons.indexOf(event.currentTarget);
if (currentIdx < 0) return;
event.preventDefault();
const nextIdx =
event.key === 'ArrowDown'
? Math.min(currentIdx + 1, buttons.length - 1)
: Math.max(currentIdx - 1, 0);
if (nextIdx !== currentIdx) {
buttons[nextIdx].focus();
}
};
return (
<div className="flex flex-col h-full">
@@ -54,46 +108,68 @@ const InstalledServerList = ({
</button>
</div>
) : (
<ul className="space-y-1 flex-1 overflow-y-auto">
{servers.map(server => {
const connStatus = statusMap.get(server.server_id);
const status: ServerStatus = connStatus?.status ?? 'disconnected';
const toolCount = connStatus?.tool_count ?? 0;
const isSelected = selectedId === server.server_id;
<>
{isFiltering && (
<p
role="status"
aria-live="polite"
className="mb-2 text-[11px] text-stone-500 dark:text-neutral-400">
{t('mcp.installed.search.countMatches')
.replace('{shown}', String(filteredServers.length))
.replace('{total}', String(servers.length))}
</p>
)}
{filteredServers.length === 0 ? (
<div className="flex-1 flex items-center justify-center text-center py-8">
<p className="text-sm text-stone-400 dark:text-neutral-500">
{t('mcp.installed.search.noMatches').replace('{query}', filter.trim())}
</p>
</div>
) : (
<ul ref={listRef} className="space-y-1 flex-1 overflow-y-auto">
{filteredServers.map(server => {
const connStatus = statusMap.get(server.server_id);
const status: ServerStatus = connStatus?.status ?? 'disconnected';
const toolCount = connStatus?.tool_count ?? 0;
const isSelected = selectedId === server.server_id;
return (
<li key={server.server_id}>
<button
type="button"
onClick={() => onSelect(server.server_id)}
className={`w-full flex items-center gap-2.5 rounded-lg px-3 py-2.5 text-left transition-colors ${
isSelected
? 'bg-primary-50 dark:bg-primary-500/15 border border-primary-200 dark:border-primary-500/30'
: 'hover:bg-stone-50 dark:hover:bg-neutral-800/60 border border-transparent'
}`}>
<span
className={`w-2 h-2 rounded-full shrink-0 ${STATUS_DOT[status]}`}
title={status}
/>
<span className="flex-1 min-w-0">
<span className="block text-sm font-medium text-stone-800 dark:text-neutral-100 truncate">
{server.display_name}
</span>
{status === 'connected' && toolCount > 0 && (
<span className="block text-[11px] text-stone-400 dark:text-neutral-500">
{t(
toolCount === 1
? 'mcp.installed.toolSingular'
: 'mcp.installed.toolPlural'
).replace('{count}', String(toolCount))}
return (
<li key={server.server_id}>
<button
type="button"
data-server-id={server.server_id}
onClick={() => onSelect(server.server_id)}
onKeyDown={handleItemKeyDown}
className={`w-full flex items-center gap-2.5 rounded-lg px-3 py-2.5 text-left transition-colors ${
isSelected
? 'bg-primary-50 dark:bg-primary-500/15 border border-primary-200 dark:border-primary-500/30'
: 'hover:bg-stone-50 dark:hover:bg-neutral-800/60 border border-transparent'
}`}>
<span
className={`w-2 h-2 rounded-full shrink-0 ${STATUS_DOT[status]}`}
title={t(STATUS_I18N_KEYS[status])}
/>
<span className="flex-1 min-w-0">
<span className="block text-sm font-medium text-stone-800 dark:text-neutral-100 truncate">
{server.display_name}
</span>
{status === 'connected' && toolCount > 0 && (
<span className="block text-[11px] text-stone-400 dark:text-neutral-500">
{t(
toolCount === 1
? 'mcp.installed.toolSingular'
: 'mcp.installed.toolPlural'
).replace('{count}', String(toolCount))}
</span>
)}
</span>
)}
</span>
</button>
</li>
);
})}
</ul>
</button>
</li>
);
})}
</ul>
)}
</>
)}
</div>
);
@@ -0,0 +1,63 @@
/**
* Tests for McpServerSearch — controlled filter input with clear button.
*/
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import McpServerSearch from './McpServerSearch';
describe('McpServerSearch', () => {
it('renders the search landmark with an accessible label', () => {
render(<McpServerSearch value="" onChange={() => {}} />);
const landmark = screen.getByRole('search', { name: 'Search installed MCP servers' });
expect(landmark).toBeInTheDocument();
});
it('renders a search-type input with placeholder and aria-label', () => {
render(<McpServerSearch value="" onChange={() => {}} />);
const input = screen.getByRole('searchbox', { name: 'Filter installed MCP servers by name' });
expect(input).toHaveAttribute('type', 'search');
expect(input).toHaveAttribute('placeholder', 'Filter servers…');
});
it('does not render the clear button when value is empty', () => {
render(<McpServerSearch value="" onChange={() => {}} />);
expect(screen.queryByRole('button', { name: 'Clear filter' })).not.toBeInTheDocument();
});
it('renders the clear button when value is non-empty', () => {
render(<McpServerSearch value="redis" onChange={() => {}} />);
expect(screen.getByRole('button', { name: 'Clear filter' })).toBeInTheDocument();
});
it('reflects the current value as the input value', () => {
render(<McpServerSearch value="redis" onChange={() => {}} />);
expect(
screen.getByRole('searchbox', { name: 'Filter installed MCP servers by name' })
).toHaveValue('redis');
});
it('fires onChange with the new value on typing', () => {
const onChange = vi.fn();
render(<McpServerSearch value="" onChange={onChange} />);
const input = screen.getByRole('searchbox', { name: 'Filter installed MCP servers by name' });
fireEvent.change(input, { target: { value: 'gh' } });
expect(onChange).toHaveBeenCalledWith('gh');
});
it('fires onChange with an empty string when the clear button is clicked', () => {
const onChange = vi.fn();
render(<McpServerSearch value="redis" onChange={onChange} />);
fireEvent.click(screen.getByRole('button', { name: 'Clear filter' }));
expect(onChange).toHaveBeenCalledWith('');
});
it('renders the clear icon as decorative (aria-hidden)', () => {
const { container } = render(<McpServerSearch value="x" onChange={() => {}} />);
// The svg inside the clear button must be aria-hidden so the button's
// own aria-label is the sole accessible name.
const svg = container.querySelector('button[aria-label="Clear filter"] svg');
expect(svg).not.toBeNull();
expect(svg).toHaveAttribute('aria-hidden', 'true');
});
});
@@ -0,0 +1,54 @@
/**
* Filter input for the installed MCP server list.
*
* Controlled component — the parent (`McpServersTab`) owns the value
* and pushes it into `InstalledServerList` as the `filter` prop. The
* input exposes a clear button when non-empty and announces itself
* as a `role="search"` landmark so assistive tech can jump to it.
*
* Intentionally has NO global keyboard shortcut binding (e.g. Cmd/Ctrl+K)
* to avoid clashing with the app-wide CommandProvider in `App.tsx`.
* Users focus the input by clicking or tabbing.
*/
import { useT } from '../../../lib/i18n/I18nContext';
interface McpServerSearchProps {
value: string;
onChange: (next: string) => void;
}
const McpServerSearch = ({ value, onChange }: McpServerSearchProps) => {
const { t } = useT();
const hasValue = value.length > 0;
return (
<div role="search" aria-label={t('mcp.installed.search.landmarkAria')} className="relative">
<input
type="search"
value={value}
onChange={e => onChange(e.target.value)}
placeholder={t('mcp.installed.search.placeholder')}
aria-label={t('mcp.installed.search.inputAria')}
className="w-full rounded-lg border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-1.5 pr-7 text-xs text-stone-800 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:outline-none focus:ring-2 focus:ring-primary-400 focus:border-primary-400"
/>
{hasValue && (
<button
type="button"
onClick={() => onChange('')}
aria-label={t('mcp.installed.search.clearAria')}
className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded p-0.5 text-stone-400 hover:text-stone-700 dark:text-neutral-500 dark:hover:text-neutral-200 transition-colors">
<svg
className="w-3 h-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2.5}
aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
);
};
export default McpServerSearch;
@@ -14,6 +14,7 @@ import InstalledServerDetail from './InstalledServerDetail';
import InstalledServerList from './InstalledServerList';
import McpCatalogBrowser from './McpCatalogBrowser';
import McpInventoryPanel from './McpInventoryPanel';
import McpServerSearch from './McpServerSearch';
import type { ConnStatus, InstalledServer } from './types';
const log = debug('mcp-clients:tab');
@@ -32,6 +33,9 @@ const McpServersTab = () => {
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [rightPane, setRightPane] = useState<RightPane>({ mode: 'none' });
// Local-only filter for the installed-server list. Not persisted — the
// search is a transient scan helper, not a saved view.
const [searchFilter, setSearchFilter] = useState('');
// Sharable Inventory modal toggle. Local state — the manifest UX is
// a one-off interaction, not a saved view.
const [inventoryOpen, setInventoryOpen] = useState(false);
@@ -167,19 +171,25 @@ const McpServersTab = () => {
</button>
</div>
<div className="flex gap-4 flex-1 min-h-0">
{/* Left pane: installed list */}
{/* Left pane: search + installed list */}
<div className="w-56 shrink-0 flex flex-col">
{loadError && (
<div className="mb-2 rounded-lg border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-3 py-2 text-xs text-coral-700 dark:text-coral-300">
{loadError}
</div>
)}
{servers.length > 0 && (
<div className="mb-2">
<McpServerSearch value={searchFilter} onChange={setSearchFilter} />
</div>
)}
<InstalledServerList
servers={servers}
statuses={statuses}
selectedId={selectedServerId}
onSelect={handleSelectServer}
onBrowseCatalog={handleBrowseCatalog}
filter={searchFilter}
/>
</div>
+6
View File
@@ -1318,6 +1318,12 @@ const ar1: TranslationMap = {
'mcp.installed.empty': 'لم يتم تثبيت خوادم MCP حتى الآن.',
'mcp.installed.toolSingular': 'أداة {count}',
'mcp.installed.toolPlural': 'أدوات {count}',
'mcp.installed.search.landmarkAria': 'Search installed MCP servers',
'mcp.installed.search.inputAria': 'Filter installed MCP servers by name',
'mcp.installed.search.placeholder': 'Filter servers…',
'mcp.installed.search.clearAria': 'Clear filter',
'mcp.installed.search.countMatches': '{shown} of {total} servers',
'mcp.installed.search.noMatches': 'No servers match "{query}".',
'mcp.inventory.openButton': 'Inventory',
'mcp.inventory.openAria': 'Open the sharable MCP inventory panel',
'mcp.inventory.title': 'Sharable MCP Inventory',
+6
View File
@@ -1327,6 +1327,12 @@ const bn1: TranslationMap = {
'mcp.installed.empty': 'এখনো কোনো MCP সার্ভার ইনস্টল করা হয়নি।',
'mcp.installed.toolSingular': '{count} টুল',
'mcp.installed.toolPlural': '{count} টুল',
'mcp.installed.search.landmarkAria': 'Search installed MCP servers',
'mcp.installed.search.inputAria': 'Filter installed MCP servers by name',
'mcp.installed.search.placeholder': 'Filter servers…',
'mcp.installed.search.clearAria': 'Clear filter',
'mcp.installed.search.countMatches': '{shown} of {total} servers',
'mcp.installed.search.noMatches': 'No servers match "{query}".',
'mcp.inventory.openButton': 'Inventory',
'mcp.inventory.openAria': 'Open the sharable MCP inventory panel',
'mcp.inventory.title': 'Sharable MCP Inventory',
+6
View File
@@ -1345,6 +1345,12 @@ const de1: TranslationMap = {
'mcp.installed.empty': 'Noch keine MCP-Server installiert.',
'mcp.installed.toolSingular': '{count}-Tool',
'mcp.installed.toolPlural': '{count}-Tools',
'mcp.installed.search.landmarkAria': 'Search installed MCP servers',
'mcp.installed.search.inputAria': 'Filter installed MCP servers by name',
'mcp.installed.search.placeholder': 'Filter servers…',
'mcp.installed.search.clearAria': 'Clear filter',
'mcp.installed.search.countMatches': '{shown} of {total} servers',
'mcp.installed.search.noMatches': 'No servers match "{query}".',
'mcp.inventory.openButton': 'Inventory',
'mcp.inventory.openAria': 'Open the sharable MCP inventory panel',
'mcp.inventory.title': 'Sharable MCP Inventory',
+6
View File
@@ -1303,6 +1303,12 @@ const en1: TranslationMap = {
'mcp.installed.empty': 'No MCP servers installed yet.',
'mcp.installed.toolSingular': '{count} tool',
'mcp.installed.toolPlural': '{count} tools',
'mcp.installed.search.landmarkAria': 'Search installed MCP servers',
'mcp.installed.search.inputAria': 'Filter installed MCP servers by name',
'mcp.installed.search.placeholder': 'Filter servers…',
'mcp.installed.search.clearAria': 'Clear filter',
'mcp.installed.search.countMatches': '{shown} of {total} servers',
'mcp.installed.search.noMatches': 'No servers match "{query}".',
'mcp.inventory.openButton': 'Inventory',
'mcp.inventory.openAria': 'Open the sharable MCP inventory panel',
'mcp.inventory.title': 'Sharable MCP Inventory',
+6
View File
@@ -1342,6 +1342,12 @@ const es1: TranslationMap = {
'mcp.installed.empty': 'Aún no hay servidores MCP instalados.',
'mcp.installed.toolSingular': 'herramienta {count}',
'mcp.installed.toolPlural': '{count} herramientas',
'mcp.installed.search.landmarkAria': 'Search installed MCP servers',
'mcp.installed.search.inputAria': 'Filter installed MCP servers by name',
'mcp.installed.search.placeholder': 'Filter servers…',
'mcp.installed.search.clearAria': 'Clear filter',
'mcp.installed.search.countMatches': '{shown} of {total} servers',
'mcp.installed.search.noMatches': 'No servers match "{query}".',
'mcp.inventory.openButton': 'Inventory',
'mcp.inventory.openAria': 'Open the sharable MCP inventory panel',
'mcp.inventory.title': 'Sharable MCP Inventory',
+6
View File
@@ -1347,6 +1347,12 @@ const fr1: TranslationMap = {
'mcp.installed.empty': 'No MCP servers installed yet.',
'mcp.installed.toolSingular': 'Outil {count}',
'mcp.installed.toolPlural': 'Outils {count}',
'mcp.installed.search.landmarkAria': 'Search installed MCP servers',
'mcp.installed.search.inputAria': 'Filter installed MCP servers by name',
'mcp.installed.search.placeholder': 'Filter servers…',
'mcp.installed.search.clearAria': 'Clear filter',
'mcp.installed.search.countMatches': '{shown} of {total} servers',
'mcp.installed.search.noMatches': 'No servers match "{query}".',
'mcp.inventory.openButton': 'Inventory',
'mcp.inventory.openAria': 'Open the sharable MCP inventory panel',
'mcp.inventory.title': 'Sharable MCP Inventory',
+6
View File
@@ -1326,6 +1326,12 @@ const hi1: TranslationMap = {
'mcp.installed.empty': 'अभी तक कोई MCP सर्वर स्थापित नहीं है।',
'mcp.installed.toolSingular': '{count} उपकरण',
'mcp.installed.toolPlural': '{count} उपकरण',
'mcp.installed.search.landmarkAria': 'Search installed MCP servers',
'mcp.installed.search.inputAria': 'Filter installed MCP servers by name',
'mcp.installed.search.placeholder': 'Filter servers…',
'mcp.installed.search.clearAria': 'Clear filter',
'mcp.installed.search.countMatches': '{shown} of {total} servers',
'mcp.installed.search.noMatches': 'No servers match "{query}".',
'mcp.inventory.openButton': 'Inventory',
'mcp.inventory.openAria': 'Open the sharable MCP inventory panel',
'mcp.inventory.title': 'Sharable MCP Inventory',
+6
View File
@@ -1330,6 +1330,12 @@ const id1: TranslationMap = {
'mcp.installed.empty': 'Belum ada server MCP yang terinstal.',
'mcp.installed.toolSingular': '{count} alat',
'mcp.installed.toolPlural': '{count} alat',
'mcp.installed.search.landmarkAria': 'Search installed MCP servers',
'mcp.installed.search.inputAria': 'Filter installed MCP servers by name',
'mcp.installed.search.placeholder': 'Filter servers…',
'mcp.installed.search.clearAria': 'Clear filter',
'mcp.installed.search.countMatches': '{shown} of {total} servers',
'mcp.installed.search.noMatches': 'No servers match "{query}".',
'mcp.inventory.openButton': 'Inventory',
'mcp.inventory.openAria': 'Open the sharable MCP inventory panel',
'mcp.inventory.title': 'Sharable MCP Inventory',
+6
View File
@@ -1335,6 +1335,12 @@ const it1: TranslationMap = {
'mcp.installed.empty': 'Nessun server MCP ancora installato.',
'mcp.installed.toolSingular': '{count} strumento',
'mcp.installed.toolPlural': '{count} strumenti',
'mcp.installed.search.landmarkAria': 'Search installed MCP servers',
'mcp.installed.search.inputAria': 'Filter installed MCP servers by name',
'mcp.installed.search.placeholder': 'Filter servers…',
'mcp.installed.search.clearAria': 'Clear filter',
'mcp.installed.search.countMatches': '{shown} of {total} servers',
'mcp.installed.search.noMatches': 'No servers match "{query}".',
'mcp.inventory.openButton': 'Inventory',
'mcp.inventory.openAria': 'Open the sharable MCP inventory panel',
'mcp.inventory.title': 'Sharable MCP Inventory',
+6
View File
@@ -1327,6 +1327,12 @@ const ko1: TranslationMap = {
'mcp.installed.empty': '아직 MCP 서버가 설치되지 않았습니다.',
'mcp.installed.toolSingular': '{count} 도구',
'mcp.installed.toolPlural': '{count} 도구',
'mcp.installed.search.landmarkAria': 'Search installed MCP servers',
'mcp.installed.search.inputAria': 'Filter installed MCP servers by name',
'mcp.installed.search.placeholder': 'Filter servers…',
'mcp.installed.search.clearAria': 'Clear filter',
'mcp.installed.search.countMatches': '{shown} of {total} servers',
'mcp.installed.search.noMatches': 'No servers match "{query}".',
'mcp.inventory.openButton': 'Inventory',
'mcp.inventory.openAria': 'Open the sharable MCP inventory panel',
'mcp.inventory.title': 'Sharable MCP Inventory',
+6
View File
@@ -1340,6 +1340,12 @@ const pt1: TranslationMap = {
'mcp.installed.empty': 'Nenhum servidor MCP instalado ainda.',
'mcp.installed.toolSingular': 'Ferramenta {count}',
'mcp.installed.toolPlural': 'Ferramentas {count}',
'mcp.installed.search.landmarkAria': 'Search installed MCP servers',
'mcp.installed.search.inputAria': 'Filter installed MCP servers by name',
'mcp.installed.search.placeholder': 'Filter servers…',
'mcp.installed.search.clearAria': 'Clear filter',
'mcp.installed.search.countMatches': '{shown} of {total} servers',
'mcp.installed.search.noMatches': 'No servers match "{query}".',
'mcp.inventory.openButton': 'Inventory',
'mcp.inventory.openAria': 'Open the sharable MCP inventory panel',
'mcp.inventory.title': 'Sharable MCP Inventory',
+6
View File
@@ -1330,6 +1330,12 @@ const ru1: TranslationMap = {
'mcp.installed.empty': 'Серверы MCP пока не установлены.',
'mcp.installed.toolSingular': '{count} инструмент',
'mcp.installed.toolPlural': '{count} инструменты',
'mcp.installed.search.landmarkAria': 'Search installed MCP servers',
'mcp.installed.search.inputAria': 'Filter installed MCP servers by name',
'mcp.installed.search.placeholder': 'Filter servers…',
'mcp.installed.search.clearAria': 'Clear filter',
'mcp.installed.search.countMatches': '{shown} of {total} servers',
'mcp.installed.search.noMatches': 'No servers match "{query}".',
'mcp.inventory.openButton': 'Inventory',
'mcp.inventory.openAria': 'Open the sharable MCP inventory panel',
'mcp.inventory.title': 'Sharable MCP Inventory',
+6
View File
@@ -1311,6 +1311,12 @@ const zhCN1: TranslationMap = {
'mcp.installed.empty': '尚未安装 MCP 服务器。',
'mcp.installed.toolSingular': '{count} 工具',
'mcp.installed.toolPlural': '{count} 工具',
'mcp.installed.search.landmarkAria': 'Search installed MCP servers',
'mcp.installed.search.inputAria': 'Filter installed MCP servers by name',
'mcp.installed.search.placeholder': 'Filter servers…',
'mcp.installed.search.clearAria': 'Clear filter',
'mcp.installed.search.countMatches': '{shown} of {total} servers',
'mcp.installed.search.noMatches': 'No servers match "{query}".',
'mcp.inventory.openButton': 'Inventory',
'mcp.inventory.openAria': 'Open the sharable MCP inventory panel',
'mcp.inventory.title': 'Sharable MCP Inventory',
+6
View File
@@ -927,6 +927,12 @@ const en: TranslationMap = {
'mcp.installed.empty': 'No MCP servers installed yet.',
'mcp.installed.toolSingular': '{count} tool',
'mcp.installed.toolPlural': '{count} tools',
'mcp.installed.search.landmarkAria': 'Search installed MCP servers',
'mcp.installed.search.inputAria': 'Filter installed MCP servers by name',
'mcp.installed.search.placeholder': 'Filter servers…',
'mcp.installed.search.clearAria': 'Clear filter',
'mcp.installed.search.countMatches': '{shown} of {total} servers',
'mcp.installed.search.noMatches': 'No servers match "{query}".',
'mcp.inventory.openButton': 'Inventory',
'mcp.inventory.openAria': 'Open the sharable MCP inventory panel',
'mcp.inventory.title': 'Sharable MCP Inventory',