feat(mcp): complete the MCP Servers page — curated catalog, UX, lifecycle & E2E (#4272) (#4300)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: M3gA-Mind <megamind@mahadao.com>
This commit is contained in:
YellowSnnowmann
2026-06-30 22:37:41 +05:30
committed by GitHub
co-authored by Claude Opus 4.8 M3gA-Mind
parent 7bf18562a1
commit 07140e9c0b
33 changed files with 1325 additions and 468 deletions
@@ -34,8 +34,10 @@ export function clearConfigChat(qualifiedName: string): void {
interface ConfigAssistantPanelProps {
qualifiedName: string;
onApplySuggestedEnv?: (env: Record<string, string>) => void;
/** A fixed, server-specific prompt auto-sent once on mount (so the user gets
* guidance with a single click instead of having to know what to ask). */
/** A fixed, server-specific prompt offered as a one-click action in the empty
* state. It is NOT auto-sent: firing the LLM research call on open made the
* help panel block for seconds before showing anything (#4272). The user taps
* the suggestion when they actually want guidance. */
autoPrompt?: string;
}
@@ -111,16 +113,6 @@ const ConfigAssistantPanel = ({
chatCache.set(qualifiedName, messages);
}, [qualifiedName, messages]);
// Auto-run the fixed prompt once — but only for a fresh chat. If we restored
// an existing conversation from the cache, don't re-ask.
const autoSent = useRef(false);
useEffect(() => {
if (autoPrompt && !autoSent.current && messages.length === 0) {
autoSent.current = true;
void send(autoPrompt);
}
}, [autoPrompt, send, messages.length]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
@@ -136,9 +128,18 @@ const ConfigAssistantPanel = ({
{/* Message list */}
<div className="flex-1 overflow-y-auto space-y-2 min-h-0 rounded-lg border border-line-subtle p-2">
{messages.length === 0 && (
<p className="text-xs text-content-faint py-2 text-center">
{t('mcp.configAssistant.empty')}
</p>
<div className="py-2 text-center space-y-2">
<p className="text-xs text-content-faint">{t('mcp.configAssistant.empty')}</p>
{autoPrompt && (
<Button
variant="secondary"
size="sm"
disabled={sending}
onClick={() => void send(autoPrompt)}>
{t('mcp.configAssistant.autoPromptCta')}
</Button>
)}
</div>
)}
{messages.map((msg, idx) => (
<div
@@ -14,10 +14,10 @@ describe('ConfigHelpModal', () => {
beforeEach(() => {
mockConfigAssist.mockReset();
// The embedded ConfigAssistantPanel caches chat history per qualified_name at
// module scope; clear it so each test starts with a fresh chat and the
// auto-prompt fires again (a restored, non-empty chat suppresses the auto-run).
// module scope; clear it so each test starts with a fresh chat (and the
// one-click setup-help action is offered again).
clearConfigChat('acme/test-server');
// The auto-sent prompt resolves so the auto-run doesn't surface as an error.
// The on-demand prompt resolves when the user triggers it.
mockConfigAssist.mockResolvedValue({ reply: 'Get a token from the dashboard.' });
});
@@ -35,9 +35,13 @@ describe('ConfigHelpModal', () => {
expect(screen.getByRole('heading', { name: 'Help & configure' })).toBeInTheDocument();
// Embedded ConfigAssistantPanel renders its input.
expect(screen.getByPlaceholderText(/ask a question/i)).toBeInTheDocument();
// The help opens instantly — no blocking LLM call on open (#4272). The
// research is offered as a one-click action instead.
expect(mockConfigAssist).not.toHaveBeenCalled();
expect(screen.getByRole('button', { name: 'Get step-by-step setup help' })).toBeInTheDocument();
});
it('auto-runs a server-specific prompt naming the display name and qualified name', async () => {
it('runs a server-specific prompt on demand, naming the display name and qualified name', async () => {
render(
<ConfigHelpModal
qualifiedName="acme/test-server"
@@ -46,19 +50,21 @@ describe('ConfigHelpModal', () => {
onClose={() => {}}
/>
);
// Nothing fires until the user asks for help.
expect(mockConfigAssist).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: 'Get step-by-step setup help' }));
await waitFor(() => {
expect(mockConfigAssist).toHaveBeenCalledTimes(1);
});
const [{ qualified_name, user_message }] = mockConfigAssist.mock.calls[0];
expect(qualified_name).toBe('acme/test-server');
// The auto prompt embeds both the friendly name and the qualified name and
// the description.
// The prompt embeds the friendly name, the qualified name, and description.
expect(user_message).toContain('Test Server');
expect(user_message).toContain('acme/test-server');
expect(user_message).toContain('A test MCP server');
});
it('builds an auto prompt that omits the description sentence when none is given', async () => {
it('builds an on-demand prompt that omits the description sentence when none is given', async () => {
render(
<ConfigHelpModal
qualifiedName="acme/test-server"
@@ -66,6 +72,7 @@ describe('ConfigHelpModal', () => {
onClose={() => {}}
/>
);
fireEvent.click(screen.getByRole('button', { name: 'Get step-by-step setup help' }));
await waitFor(() => {
expect(mockConfigAssist).toHaveBeenCalledTimes(1);
});
@@ -116,7 +123,9 @@ describe('ConfigHelpModal', () => {
onApplySuggestedEnv={onApply}
/>
);
// The auto-run reply carries suggested_env, so the Apply button appears.
// Trigger the on-demand help; the reply carries suggested_env, so the Apply
// button appears.
fireEvent.click(screen.getByRole('button', { name: 'Get step-by-step setup help' }));
const applyBtn = await screen.findByRole('button', { name: 'Apply suggested values' });
fireEvent.click(applyBtn);
expect(onApply).toHaveBeenCalledWith({ API_KEY: 'abc' });
@@ -62,8 +62,30 @@ describe('InstallDialog', () => {
expect(screen.getByText('Test Server')).toBeInTheDocument();
});
expect(screen.getByText('A test server')).toBeInTheDocument();
expect(screen.getByText('Requires configuration')).toBeInTheDocument();
expect(screen.getByText('Runs locally')).toBeInTheDocument();
// Transport badge uses the same Stdio/Hosted vocabulary as the catalog list
// (the old separate "Cloud hosted"/"Requires configuration" pills were
// dropped — the env-vars section below already conveys configuration).
// Derived from the connection type (stdio here), NOT the detail DTO's
// absent `is_deployed`.
expect(screen.getByText('Stdio')).toBeInTheDocument();
});
it('labels a server with an http connection as Hosted', async () => {
mockRegistryGet.mockResolvedValue({
...DETAIL,
qualified_name: 'acme/hosted-server',
display_name: 'Hosted Server',
connections: [{ type: 'http', deployment_url: 'https://x.example/mcp', published: true }],
});
render(
<InstallDialog qualifiedName="acme/hosted-server" onSuccess={() => {}} onCancel={() => {}} />
);
await waitFor(() => expect(screen.getByText('Hosted Server')).toBeInTheDocument());
// The detail DTO carries no `is_deployed`; the badge must derive from the
// http connection (this regressed to always-"Stdio" before the fix).
expect(screen.getByText('Hosted')).toBeInTheDocument();
expect(screen.queryByText('Stdio')).not.toBeInTheDocument();
});
it('shows env key preview badges on detail step', async () => {
@@ -17,7 +17,7 @@ import { useT } from '../../../lib/i18n/I18nContext';
import { mcpClientsApi } from '../../../services/api/mcpClientsApi';
import Button from '../../ui/Button';
import { deriveAuthor } from './McpServerCard';
import type { InstalledServer, SmitheryConnection, SmitheryServerDetail } from './types';
import type { InstalledServer, SmitheryServerDetail } from './types';
const log = debug('mcp-clients:install');
@@ -30,13 +30,6 @@ interface InstallDialogProps {
onCancel: () => void;
}
function pickTransportLabel(connections: SmitheryConnection[]): string | null {
const types = new Set(connections.map(c => c.type));
if (types.has('stdio')) return 'stdio';
if (types.has('http')) return 'http';
return connections[0]?.type ?? null;
}
function formatUseCount(count: number): string {
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
@@ -198,7 +191,10 @@ const InstallDialog = ({ qualifiedName, prefillEnv, onSuccess, onCancel }: Insta
if (!detail) return null;
const author = deriveAuthor(qualifiedName);
const transport = pickTransportLabel(detail.connections);
// The detail DTO doesn't carry `is_deployed`, so derive transport from the
// connection types instead (an `http` connection ⇒ hosted, else stdio) —
// otherwise hosted-only servers are always mislabeled "Stdio".
const isHosted = (detail.connections ?? []).some(c => c.type === 'http');
// ── Step 1: Detail overview ──────────────────────────────────────────────
@@ -236,30 +232,19 @@ const InstallDialog = ({ qualifiedName, prefillEnv, onSuccess, onCancel }: Insta
</div>
</div>
{/* Stats badges */}
{/* Stats badges — same Hosted/Stdio vocabulary as the catalog list (no
separate "Cloud hosted"/"Requires configuration" pills: the
transport badge and the "Required environment variables" section
below already convey both). */}
<div className="flex flex-wrap gap-2">
{transport && (
<span className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium bg-surface-subtle text-content-secondary">
{transport === 'stdio'
? t('mcp.install.transportLocal')
: t('mcp.install.transportRemote')}
</span>
)}
<span className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium bg-surface-subtle text-content-secondary">
{isHosted ? t('mcp.tab.transport.hosted') : t('mcp.tab.transport.local')}
</span>
{detail.use_count != null && detail.use_count > 0 && (
<span className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium bg-surface-subtle text-content-secondary">
{t('mcp.install.useCount').replace('{count}', formatUseCount(detail.use_count))}
</span>
)}
{detail.is_deployed && (
<span className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium bg-sage-100 dark:bg-sage-500/20 text-sage-700 dark:text-sage-300">
{t('mcp.install.deployed')}
</span>
)}
{hasEnvKeys && (
<span className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-300">
{t('mcp.install.requiresConfig')}
</span>
)}
</div>
{/* Description */}
@@ -5,7 +5,7 @@
* navigation to detail/install views, install success, uninstall, and
* status polling.
*/
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import McpServersTab from './McpServersTab';
@@ -20,6 +20,11 @@ const mockSetEnabled = vi.fn();
const mockRegistryGet = vi.fn();
const mockRegistrySearch = vi.fn();
const mockConfigAssist = vi.fn();
const mockOpenUrl = vi.fn();
vi.mock('../../../utils/openUrl', () => ({
openUrl: (...args: unknown[]) => mockOpenUrl(...args),
}));
vi.mock('../../../services/api/mcpClientsApi', () => ({
mcpClientsApi: {
@@ -107,6 +112,8 @@ describe('McpServersTab', () => {
mockRegistryGet.mockReset();
mockRegistrySearch.mockReset();
mockRegistrySearch.mockResolvedValue({ servers: [], page: 1, total_pages: 1 });
mockOpenUrl.mockReset();
mockOpenUrl.mockResolvedValue(undefined);
});
afterEach(() => {
@@ -325,7 +332,7 @@ describe('McpServersTab', () => {
expect(screen.getAllByText('Server B')).toHaveLength(1);
});
it('distinguishes look-alike registry rows by slug and source badge', async () => {
it('distinguishes look-alike registry rows by slug', async () => {
mockInstalledList.mockResolvedValue([]);
mockStatus.mockResolvedValue([]);
mockRegistrySearch.mockResolvedValue({
@@ -352,11 +359,15 @@ describe('McpServersTab', () => {
// Both rows share the display name "gmail"...
await waitFor(() => expect(screen.getAllByText('gmail')).toHaveLength(2));
// ...but the unique slug and the registry-source badge tell them apart.
// ...but the unique slug tells them apart. The registry-source pill was
// removed: it labelled every official-registry row "Official", which falsely
// vouched for un-vetted community servers. Only the vendor `official` badge
// remains, and only on the row flagged official.
expect(screen.getByText('waystation/gmail')).toBeInTheDocument();
expect(screen.getByText('mintmcp/gmail')).toBeInTheDocument();
expect(screen.getByText('Official')).toBeInTheDocument();
expect(screen.getByText('Smithery')).toBeInTheDocument();
// Both fixture rows are flagged official → two vendor badges, no source pill.
expect(screen.getAllByText(/Official/)).toHaveLength(2);
expect(screen.queryByText('Smithery')).not.toBeInTheDocument();
});
it('renders the registry as one list (no auth-method grouping) in registry order', async () => {
@@ -381,11 +392,90 @@ describe('McpServersTab', () => {
expect(screen.queryByText('Browser sign-in')).not.toBeInTheDocument();
expect(screen.queryByText('Token / API key')).not.toBeInTheDocument();
// Rows keep their registry order (relevance), regardless of transport.
const tableText = screen.getByRole('table').textContent ?? '';
const table = screen.getByRole('table');
const tableText = table.textContent ?? '';
expect(tableText.indexOf('Local One')).toBeLessThan(tableText.indexOf('Hosted One'));
// Per-row Hosted/Local hint badges still render.
expect(screen.getByText('Hosted')).toBeInTheDocument();
expect(screen.getByText('Local')).toBeInTheDocument();
// Per-row transport badges render (Stdio vs Hosted). Scope to the table —
// "Stdio"/"Hosted" also appear as the transport filter chips above it.
expect(within(table).getByText('Hosted')).toBeInTheDocument();
expect(within(table).getByText('Stdio')).toBeInTheDocument();
});
it('renders clickable website + repo links that open externally without installing', async () => {
mockInstalledList.mockResolvedValue([]);
mockStatus.mockResolvedValue([]);
mockRegistrySearch.mockResolvedValue({
servers: [
{
qualified_name: 'io.github.acme/cool-mcp',
display_name: 'Cool MCP',
is_deployed: true,
website_url: 'https://acme.example',
},
],
page: 1,
total_pages: 1,
});
await renderAndWaitForLoad();
vi.useRealTimers();
await waitFor(() => screen.getByText('Cool MCP'));
// Website link opens the declared site; repo link is derived from the
// io.github.<user>/<repo> slug. Neither should trigger the row's install nav.
fireEvent.click(screen.getByText('Website'));
expect(mockOpenUrl).toHaveBeenCalledWith('https://acme.example');
fireEvent.click(screen.getByText('Repository'));
expect(mockOpenUrl).toHaveBeenCalledWith('https://github.com/acme/cool-mcp');
// Still on the catalog list — clicking a link did not navigate to install.
expect(screen.getByText('Cool MCP')).toBeInTheDocument();
expect(screen.queryByText('Install Cool MCP')).not.toBeInTheDocument();
});
it('re-queries the core with a transport filter when the Stdio/Hosted pills are toggled', async () => {
mockInstalledList.mockResolvedValue([]);
mockStatus.mockResolvedValue([]);
// Transport filtering now happens in the core: the mock answers per the
// `transport` param it receives, and toggling a pill re-queries.
const STDIO = { qualified_name: 'a/stdio-srv', display_name: 'Stdio Srv', is_deployed: false };
const HOSTED = {
qualified_name: 'b/hosted-srv',
display_name: 'Hosted Srv',
is_deployed: true,
};
mockRegistrySearch.mockImplementation((params: { transport?: string }) => {
const servers =
params.transport === 'stdio'
? [STDIO]
: params.transport === 'hosted'
? [HOSTED]
: [STDIO, HOSTED];
return Promise.resolve({ servers, page: 1, total_pages: 1 });
});
await renderAndWaitForLoad();
vi.useRealTimers();
await waitFor(() => screen.getByText('Hosted Srv'));
expect(screen.getByText('Stdio Srv')).toBeInTheDocument();
// Toggle "Stdio" → core is re-queried with transport:'stdio'; hosted drops.
fireEvent.click(screen.getByRole('button', { name: 'Stdio' }));
await waitFor(() =>
expect(mockRegistrySearch).toHaveBeenCalledWith(
expect.objectContaining({ transport: 'stdio' })
)
);
await waitFor(() => expect(screen.queryByText('Hosted Srv')).not.toBeInTheDocument());
expect(screen.getByText('Stdio Srv')).toBeInTheDocument();
// Switch to "Hosted" → transport:'hosted'; stdio drops.
fireEvent.click(screen.getByRole('button', { name: 'Hosted' }));
await waitFor(() => expect(screen.getByText('Hosted Srv')).toBeInTheDocument());
expect(screen.queryByText('Stdio Srv')).not.toBeInTheDocument();
});
it('renders every returned row and badges only the official one, with no Show all toggle', async () => {
@@ -426,6 +516,54 @@ describe('McpServersTab', () => {
expect(screen.queryByText('Verified only')).not.toBeInTheDocument();
});
it('shows a registry error state with retry when the catalog fetch fails', async () => {
mockInstalledList.mockResolvedValue([]);
mockStatus.mockResolvedValue([]);
// First catalog fetch fails → error state; retry succeeds → rows render.
mockRegistrySearch.mockRejectedValueOnce(new Error('registry down'));
mockRegistrySearch.mockResolvedValue({
servers: [{ qualified_name: 'a/srv', display_name: 'Recovered Srv', is_deployed: false }],
page: 1,
total_pages: 1,
});
await renderAndWaitForLoad();
vi.useRealTimers();
// Error surfaces instead of a silent empty state.
const errorBox = await screen.findByTestId('mcp-catalog-error');
expect(errorBox).toHaveTextContent('Failed to load catalog');
expect(screen.queryByTestId('mcp-catalog-empty')).not.toBeInTheDocument();
// Retry re-fetches and renders the recovered catalog.
fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
await waitFor(() => expect(screen.getByText('Recovered Srv')).toBeInTheDocument());
expect(screen.queryByTestId('mcp-catalog-error')).not.toBeInTheDocument();
});
it('surfaces the health toolbar and reconnects error-state servers via Retry all', async () => {
mockInstalledList.mockResolvedValue(SERVERS);
mockStatus.mockResolvedValue([
{
server_id: 'srv-1',
qualified_name: 'acme/fs-server',
display_name: 'File Server',
status: 'error' as const,
tool_count: 0,
last_error: 'connect failed',
},
]);
await renderAndWaitForLoad();
vi.useRealTimers();
// The toolbar (previously never rendered in production) now appears, and its
// "Retry all" affordance reconnects the errored server.
const retry = await screen.findByRole('button', { name: /retry all/i });
fireEvent.click(retry);
await waitFor(() => expect(mockConnect).toHaveBeenCalledWith('srv-1'));
});
it('navigates to install view when a registry server row is clicked', async () => {
mockInstalledList.mockResolvedValue([]);
mockStatus.mockResolvedValue([]);
+368 -193
View File
@@ -6,14 +6,16 @@
* between "All", "Installed", and "Registry" views.
*/
import debug from 'debug';
import { useCallback, useEffect, useRef, useState } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import { mcpClientsApi } from '../../../services/api/mcpClientsApi';
import { openUrl } from '../../../utils/openUrl';
import ChipTabs from '../../layout/ChipTabs';
import Button from '../../ui/Button';
import InstallDialog from './InstallDialog';
import InstalledServerDetail from './InstalledServerDetail';
import McpConnectionHealthToolbar from './McpConnectionHealthToolbar';
import McpInventoryPanel from './McpInventoryPanel';
import { deriveAuthor } from './McpServerCard';
import type { ConnStatus, InstalledServer, ServerStatus, SmitheryServer } from './types';
@@ -73,56 +75,181 @@ const STATUS_DOT: Record<ServerStatus, string> = {
disabled: 'bg-surface-strong',
};
// Maps an upstream registry id to its i18n label key. The official
// modelcontextprotocol.io registry is highlighted (sage) over Smithery so the
// authoritative source reads as the "top" one.
const SOURCE_LABEL_KEY: Record<string, string> = {
mcp_official: 'mcp.tab.source.official',
smithery: 'mcp.tab.source.smithery',
};
/** Pill attributing a catalog row to the registry it came from. */
const SourceBadge = ({ source }: { source?: string }) => {
const { t } = useT();
const labelKey = source ? SOURCE_LABEL_KEY[source] : undefined;
if (!labelKey) {
return <span className="text-xs text-stone-400 dark:text-neutral-600"></span>;
}
const isOfficial = source === 'mcp_official';
return (
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${
isOfficial
? 'bg-sage-100 text-sage-700 dark:bg-sage-500/15 dark:text-sage-300'
: 'bg-stone-100 text-stone-600 dark:bg-neutral-800 dark:text-neutral-300'
}`}>
{t(labelKey)}
</span>
);
};
/** Transport classification a catalog row can be filtered by. */
type Transport = 'hosted' | 'stdio';
/**
* Tiny hint on how a catalog row is reached: a hosted server (has an HTTP
* endpoint) can offer browser sign-in; a local server is run on-device and
* configured with a pasted token. This mirrors the `is_deployed` sort that
* floats hosted servers to the top.
* Classify a catalog row by how it runs: `hosted` = reachable over an HTTP
* endpoint (cloud-run); `stdio` = installed and run on-device as a subprocess.
* `is_deployed` is set by the registry adapter when the server exposes a remote.
*/
const TransportHintBadge = ({ deployed }: { deployed?: boolean }) => {
const transportOf = (server: SmitheryServer): Transport =>
server.is_deployed ? 'hosted' : 'stdio';
/**
* Derive a browsable source-repository URL from the registry slug. The official
* registry namespaces community servers as `io.github.<user>/<repo>` (and
* `io.gitlab.<user>/…`), which maps 1:1 to a repo page. Returns `null` for
* vendor reverse-DNS slugs that don't encode a code host.
*/
export const deriveRepoUrl = (qualifiedName: string): string | null => {
const slash = qualifiedName.indexOf('/');
if (slash < 1) return null;
const prefix = qualifiedName.slice(0, slash);
const repo = qualifiedName.slice(slash + 1);
if (!repo) return null;
if (prefix.startsWith('io.github.')) {
return `https://github.com/${prefix.slice('io.github.'.length)}/${repo}`;
}
if (prefix.startsWith('io.gitlab.')) {
return `https://gitlab.com/${prefix.slice('io.gitlab.'.length)}/${repo}`;
}
return null;
};
/** Transport pill (Stdio vs Hosted) — the catalog's primary classification. */
const TransportBadge = ({ transport }: { transport: Transport }) => {
const { t } = useT();
const hosted = !!deployed;
const hosted = transport === 'hosted';
return (
<span
title={t(hosted ? 'mcp.tab.transport.hostedHint' : 'mcp.tab.transport.localHint')}
className={`inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
hosted
? 'bg-primary-100 text-primary-700 dark:bg-primary-500/15 dark:text-primary-300'
: 'bg-stone-100 text-stone-500 dark:bg-neutral-800 dark:text-neutral-400'
: 'bg-surface-strong text-content-muted'
}`}>
{t(hosted ? 'mcp.tab.transport.hosted' : 'mcp.tab.transport.local')}
</span>
);
};
/**
* External link that opens in the system browser. Stops propagation so clicking
* a server's website/repo never also triggers the row's install action.
*/
const ExternalLink = ({ href, label }: { href: string; label: string }) => (
<button
type="button"
onClick={e => {
e.stopPropagation();
void openUrl(href).catch(() => {});
}}
className="inline-flex items-center gap-0.5 text-[11px] text-primary-600 dark:text-primary-400 hover:underline">
{label}
<svg
className="w-2.5 h-2.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true">
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</button>
);
/**
* One catalog (registry) row. Memoized so the 5s status poll — which re-renders
* the parent tab — doesn't re-render the (potentially large) catalog list: the
* catalog data and the install handler are stable across polls, so memo skips
* every row. This is the main lever against the "rendering is slow" report.
*/
const CatalogRow = memo(
({
server,
onInstall,
}: {
server: SmitheryServer;
onInstall: (qualifiedName: string) => void;
}) => {
const { t } = useT();
const repoUrl = deriveRepoUrl(server.qualified_name);
const author = deriveAuthor(server.qualified_name);
return (
<tr
className="hover:bg-surface-muted dark:hover:bg-surface-muted/40 cursor-pointer transition-colors"
tabIndex={0}
role="button"
aria-label={t('mcp.tab.aria.installServer').replace('{name}', server.display_name)}
onClick={() => onInstall(server.qualified_name)}
onKeyDown={e => {
// Only act on keys aimed at the row itself — Enter/Space bubble up
// from the nested Website/Repository buttons, which must not open
// the install flow.
if (e.target !== e.currentTarget) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onInstall(server.qualified_name);
}
}}>
<td className="px-4 py-3">
<div className="flex items-center gap-2.5">
{server.icon_url ? (
<img
src={server.icon_url}
alt=""
className="w-5 h-5 rounded shrink-0 object-contain"
/>
) : (
<span className="w-5 h-5 rounded shrink-0 bg-primary-100 dark:bg-primary-500/20 flex items-center justify-center text-[10px]">
🔌
</span>
)}
<div className="min-w-0">
<span className="flex items-center gap-1.5">
<span className="font-medium text-content truncate">{server.display_name}</span>
{server.official && (
<span
title={t('mcp.tab.officialHint')}
className="inline-flex items-center gap-0.5 rounded-full bg-sage-100 px-1.5 py-0.5 text-[10px] font-medium text-sage-700 dark:bg-sage-500/15 dark:text-sage-300">
{t('mcp.tab.officialBadge')}
</span>
)}
</span>
{/* The registry is full of look-alike names (a dozen "gmail"
servers); the slug is the unique identifier that tells them
apart. */}
<span className="text-[11px] font-mono text-content-faint truncate block">
{server.qualified_name}
</span>
{server.description && (
<span className="text-xs text-content-faint line-clamp-3 block">
{server.description}
</span>
)}
{(server.website_url || repoUrl) && (
<span className="flex items-center gap-3 mt-1">
{server.website_url && (
<ExternalLink href={server.website_url} label={t('mcp.tab.link.website')} />
)}
{repoUrl && <ExternalLink href={repoUrl} label={t('mcp.tab.link.repo')} />}
</span>
)}
</div>
</div>
</td>
<td className="px-4 py-3 hidden sm:table-cell">
<TransportBadge transport={transportOf(server)} />
</td>
<td className="px-4 py-3 hidden sm:table-cell">
<span className="text-xs text-content-muted truncate block">{author ?? '—'}</span>
</td>
<td className="px-4 py-3 text-right">
<span className="text-xs text-primary-600 dark:text-primary-400 font-medium">
{t('mcp.install.button')}
</span>
</td>
</tr>
);
}
);
CatalogRow.displayName = 'CatalogRow';
const McpServersTab = () => {
const { t } = useT();
const [servers, setServers] = useState<InstalledServer[]>([]);
@@ -135,12 +262,17 @@ const McpServersTab = () => {
// Unified search + filter
const [searchQuery, setSearchQuery] = useState('');
const [activeChip, setActiveChip] = useState<FilterChip>('all');
// Secondary classification filter over catalog rows: by transport.
const [transportFilter, setTransportFilter] = useState<'all' | Transport>('all');
// Registry catalog results
const [catalogServers, setCatalogServers] = useState<SmitheryServer[]>([]);
const [catalogLoading, setCatalogLoading] = useState(false);
const [catalogPage, setCatalogPage] = useState(1);
const [catalogTotalPages, setCatalogTotalPages] = useState(1);
// Set when a registry fetch fails so the Registry view shows an error state
// (with retry) instead of silently falling back to an empty/stale catalog.
const [catalogError, setCatalogError] = useState(false);
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -167,42 +299,54 @@ const McpServersTab = () => {
}
}, []);
const fetchCatalog = useCallback(async (query: string, page: number, append: boolean) => {
const seq = ++requestSeqRef.current;
setCatalogLoading(true);
try {
const result = await mcpClientsApi.registrySearch({
query: query || undefined,
page,
page_size: PAGE_SIZE,
});
if (seq !== requestSeqRef.current) return;
const incoming = result.servers ?? [];
setCatalogServers(prev => dedupeByQualifiedName(append ? [...prev, ...incoming] : incoming));
setCatalogPage(result.page);
setCatalogTotalPages(result.total_pages);
} catch (err) {
if (seq !== requestSeqRef.current) return;
log('catalog fetch error: %o', err);
} finally {
if (seq === requestSeqRef.current) setCatalogLoading(false);
}
}, []);
const fetchCatalog = useCallback(
async (query: string, transport: 'all' | Transport, page: number, append: boolean) => {
const seq = ++requestSeqRef.current;
setCatalogLoading(true);
try {
const result = await mcpClientsApi.registrySearch({
query: query || undefined,
transport: transport === 'all' ? undefined : transport,
page,
page_size: PAGE_SIZE,
});
if (seq !== requestSeqRef.current) return;
const incoming = result.servers ?? [];
setCatalogServers(prev =>
dedupeByQualifiedName(append ? [...prev, ...incoming] : incoming)
);
setCatalogPage(result.page);
setCatalogTotalPages(result.total_pages);
setCatalogError(false);
} catch (err) {
if (seq !== requestSeqRef.current) return;
log('catalog fetch error: %o', err);
// A fresh (non-append) fetch that fails leaves no usable rows — surface
// the error. A failed "load more" keeps the rows already shown.
if (!append) setCatalogError(true);
} finally {
if (seq === requestSeqRef.current) setCatalogLoading(false);
}
},
[]
);
useEffect(() => {
Promise.all([loadInstalled(), fetchStatuses()]).finally(() => setLoading(false));
}, [loadInstalled, fetchStatuses]);
// Fetch catalog on mount and when search changes
// Fetch catalog (page 1) on mount and whenever the query or transport filter
// changes. Search + transport now run in the core over the cached full
// catalog, so changing either re-queries from the top.
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
void fetchCatalog(searchQuery, 1, false);
void fetchCatalog(searchQuery, transportFilter, 1, false);
}, DEBOUNCE_MS);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [searchQuery, fetchCatalog]);
}, [searchQuery, transportFilter, fetchCatalog]);
// Poll status
useEffect(() => {
@@ -274,9 +418,37 @@ const McpServersTab = () => {
);
const handleLoadMore = () => {
void fetchCatalog(searchQuery, catalogPage + 1, true);
void fetchCatalog(searchQuery, transportFilter, catalogPage + 1, true);
};
// Bulk lifecycle actions for the health toolbar. One failure doesn't abort the
// batch (allSettled), and we always refresh status so the dots reflect reality
// — but if any call rejected we then throw so the toolbar can surface the
// failure (otherwise a partial/total failure would look like success).
const handleReconnectAll = useCallback(
async (serverIds: string[]) => {
log('reconnect all: %o', serverIds);
const results = await Promise.allSettled(serverIds.map(id => mcpClientsApi.connect(id)));
await fetchStatuses();
if (results.some(r => r.status === 'rejected')) {
throw new Error(t('mcp.health.opErrorGeneric'));
}
},
[fetchStatuses, t]
);
const handleDisconnectAll = useCallback(
async (serverIds: string[]) => {
log('disconnect all: %o', serverIds);
const results = await Promise.allSettled(serverIds.map(id => mcpClientsApi.disconnect(id)));
await fetchStatuses();
if (results.some(r => r.status === 'rejected')) {
throw new Error(t('mcp.health.opErrorGeneric'));
}
},
[fetchStatuses, t]
);
const selectedServer =
view.mode === 'detail' ? (servers.find(s => s.server_id === view.serverId) ?? null) : null;
const selectedConnStatus =
@@ -286,95 +458,43 @@ const McpServersTab = () => {
// legacy double-installs can still exist on disk; collapse them by
// qualified_name (servers arrive earliest-first) so the list stays "one per
// thing". Raw `servers` is kept for server_id-keyed detail/status lookups.
const installedView = dedupeInstalledByQualifiedName(servers);
// Filter installed servers by search
const filteredInstalled = installedView.filter(s => {
if (!searchQuery.trim()) return true;
const q = searchQuery.toLowerCase();
return (
s.display_name.toLowerCase().includes(q) ||
s.qualified_name.toLowerCase().includes(q) ||
(s.description ?? '').toLowerCase().includes(q)
// Memoized so the 5s status poll doesn't rebuild + refilter the list.
const filteredInstalled = useMemo(() => {
const view = dedupeInstalledByQualifiedName(servers);
const q = searchQuery.trim().toLowerCase();
if (!q) return view;
return view.filter(
s =>
s.display_name.toLowerCase().includes(q) ||
s.qualified_name.toLowerCase().includes(q) ||
(s.description ?? '').toLowerCase().includes(q)
);
});
}, [servers, searchQuery]);
// Catalog rows minus already-installed servers. Search + transport filtering
// now happen in the core over the cached full catalog (so relevance and
// pagination are accurate); the only thing left to do client-side is hide
// servers the user already installed. Memoized so the status poll doesn't
// recompute it.
const availableCatalog = useMemo(() => {
const installedNames = new Set(servers.map(s => s.qualified_name));
return catalogServers.filter(s => !installedNames.has(s.qualified_name));
}, [catalogServers, servers]);
// Catalog rows (minus already-installed), in the registry's relevance order.
// We deliberately do NOT split by auth method here: `is_deployed` only tells
// us a server has a hosted endpoint, not whether it signs in via the browser
// or wants a pasted token — that's only known once the install dialog fetches
// the detail (`required_env_keys`) or the connect attempt hits an OAuth
// challenge. Each row carries a Hosted/Local hint badge instead; the real
// auth requirement surfaces in the install flow.
const installedNames = new Set(servers.map(s => s.qualified_name));
const availableCatalog = catalogServers.filter(s => !installedNames.has(s.qualified_name));
const showRegistry = activeChip === 'all' || activeChip === 'registry';
const renderCatalogRow = (server: SmitheryServer) => (
<tr
key={`catalog-${server.qualified_name}`}
className="hover:bg-stone-50 dark:hover:bg-neutral-800/40 cursor-pointer transition-colors"
tabIndex={0}
role="button"
aria-label={t('mcp.tab.aria.installServer').replace('{name}', server.display_name)}
onClick={() => handleSelectInstall(server.qualified_name)}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleSelectInstall(server.qualified_name);
}
}}>
<td className="px-4 py-3">
<div className="flex items-center gap-2.5">
{server.icon_url ? (
<img src={server.icon_url} alt="" className="w-5 h-5 rounded shrink-0 object-contain" />
) : (
<span className="w-5 h-5 rounded shrink-0 bg-primary-100 dark:bg-primary-500/20 flex items-center justify-center text-[10px]">
🔌
</span>
)}
<div className="min-w-0">
<span className="flex items-center gap-1.5">
<span className="font-medium text-stone-900 dark:text-neutral-100 truncate">
{server.display_name}
</span>
{server.official && (
<span
title={t('mcp.tab.officialHint')}
className="inline-flex items-center gap-0.5 rounded-full bg-sage-100 px-1.5 py-0.5 text-[10px] font-medium text-sage-700 dark:bg-sage-500/15 dark:text-sage-300">
{t('mcp.tab.officialBadge')}
</span>
)}
<TransportHintBadge deployed={server.is_deployed} />
</span>
{/* The registry is full of look-alike names (a dozen "gmail"
servers); the slug is the unique identifier that tells them
apart. */}
<span className="text-[11px] font-mono text-stone-400 dark:text-neutral-500 truncate block">
{server.qualified_name}
</span>
{server.description && (
<span className="text-xs text-stone-400 dark:text-neutral-500 line-clamp-3 block">
{server.description}
</span>
)}
</div>
</div>
</td>
<td className="px-4 py-3 hidden sm:table-cell">
<SourceBadge source={server.source} />
</td>
<td className="px-4 py-3 hidden sm:table-cell">
<span className="text-xs text-stone-500 dark:text-neutral-400 truncate block">
{deriveAuthor(server.qualified_name) ?? '—'}
</span>
</td>
<td className="px-4 py-3 text-right">
<span className="text-xs text-primary-600 dark:text-primary-400 font-medium">
{t('mcp.install.button')}
</span>
</td>
</tr>
// Render catalog rows from memoized data so they survive parent re-renders
// (the status poll) untouched — `CatalogRow` is itself memoized.
const catalogRows = useMemo(
() =>
availableCatalog.map(server => (
<CatalogRow
key={`catalog-${server.qualified_name}`}
server={server}
onInstall={handleSelectInstall}
/>
)),
[availableCatalog, handleSelectInstall]
);
const statusMap = new Map(statuses.map(s => [s.server_id, s]));
@@ -415,26 +535,11 @@ const McpServersTab = () => {
);
}
// Install view
// Install view — InstallDialog renders its own "← Go back", so the tab does
// NOT add a second one here (that was the duplicate back button).
if (view.mode === 'install') {
return (
<div className="space-y-3">
<Button
variant="tertiary"
size="xs"
onClick={() => setView({ mode: 'home' })}
leadingIcon={
<svg
className="w-3.5 h-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
}>
{t('mcp.install.back')}
</Button>
<InstallDialog
qualifiedName={view.qualifiedName}
prefillEnv={view.prefillEnv}
@@ -468,23 +573,59 @@ const McpServersTab = () => {
</Button>
</div>
{/* Filter chips */}
<ChipTabs<FilterChip>
className="flex flex-wrap items-center gap-2"
value={activeChip}
onChange={setActiveChip}
items={[
{ id: 'all', label: t('mcp.tab.filter.all') },
{
id: 'installed',
label: t('mcp.tab.filter.installed').replace(
'{count}',
String(filteredInstalled.length)
),
},
{ id: 'registry', label: t('mcp.tab.filter.registry') },
]}
/>
{/* Filters on one bar: the scope (All / Installed / Registry) chips, then —
when registry rows are visible — a labelled transport filter rendered
as Stdio/Hosted TOGGLES (no second "All" chip; deselecting both means
all). This avoids the duplicate-"All" confusion. */}
<div className="flex flex-wrap items-center gap-2">
<ChipTabs<FilterChip>
className="flex flex-wrap items-center gap-2"
value={activeChip}
onChange={setActiveChip}
items={[
{ id: 'all', label: t('mcp.tab.filter.all') },
{
id: 'installed',
label: t('mcp.tab.filter.installed').replace(
'{count}',
String(filteredInstalled.length)
),
},
{ id: 'registry', label: t('mcp.tab.filter.registry') },
]}
/>
{showRegistry && (
<>
<span className="hidden sm:block h-5 w-px bg-line-subtle" aria-hidden="true" />
<span className="text-xs font-medium text-content-muted">
{t('mcp.tab.transportFilter.label')}
</span>
<div
className="flex flex-wrap items-center gap-2"
role="group"
aria-label={t('mcp.tab.transportFilter.aria')}>
{(['stdio', 'hosted'] as const).map(tp => {
const active = transportFilter === tp;
return (
<button
key={tp}
type="button"
aria-pressed={active}
onClick={() => setTransportFilter(prev => (prev === tp ? 'all' : tp))}
className={`rounded-full px-3 py-1 text-xs font-medium transition-colors ${
active
? 'bg-content text-surface'
: 'border border-line text-content-muted hover:bg-surface-muted'
}`}>
{t(tp === 'stdio' ? 'mcp.tab.transport.local' : 'mcp.tab.transport.hosted')}
</button>
);
})}
</div>
</>
)}
</div>
{loadError && (
<div className="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">
@@ -492,6 +633,18 @@ const McpServersTab = () => {
</div>
)}
{/* Connection health + bulk lifecycle actions. Only meaningful once
servers are installed; surfaces "Retry all" for error-state servers
(the failed-connection retry affordance #4272 asks for) and
"Disconnect all". Reads the polled statuses — no extra fetches. */}
{(activeChip === 'all' || activeChip === 'installed') && statuses.length > 0 && (
<McpConnectionHealthToolbar
statuses={statuses}
onReconnect={handleReconnectAll}
onDisconnect={handleDisconnectAll}
/>
)}
{/* Table — horizontally scrollable so the Source/Author/Action columns
aren't clipped when the panel is narrower than the table's natural
width (the wrapper was `overflow-hidden`, which cut them off with no
@@ -505,7 +658,7 @@ const McpServersTab = () => {
{t('mcp.tab.column.name')}
</th>
<th className="text-left px-4 py-2.5 text-xs font-medium text-content-muted hidden sm:table-cell w-28">
{t('mcp.tab.column.source')}
{t('mcp.tab.column.type')}
</th>
<th className="text-left px-4 py-2.5 text-xs font-medium text-content-muted hidden sm:table-cell w-36">
{t('mcp.tab.column.author')}
@@ -573,12 +726,30 @@ const McpServersTab = () => {
);
})}
{/* Registry servers — one relevance-ordered list. Each row shows a
Hosted/Local hint; the real auth method appears on install. */}
{showRegistry && availableCatalog.map(renderCatalogRow)}
{/* Registry servers — official first, then the registry's relevance
order. Each row shows its transport, website/repo links, and the
real auth surfaces on install. */}
{showRegistry && catalogRows}
</tbody>
</table>
{/* Registry fetch error — takes precedence over the empty state so a
failed load reads as an error (with retry), not "no results". */}
{showRegistry && catalogError && !catalogLoading && (
<div
data-testid="mcp-catalog-error"
className="py-8 text-center text-sm text-coral-700 dark:text-coral-300 space-y-2">
<p>{t('mcp.catalog.loadFailed')}</p>
<Button
variant="tertiary"
size="xs"
onClick={() => void fetchCatalog(searchQuery, transportFilter, 1, false)}
className="text-primary-600 dark:text-primary-400 hover:underline">
{t('common.retry')}
</Button>
</div>
)}
{/* Empty states */}
{activeChip === 'installed' && filteredInstalled.length === 0 && (
<div
@@ -587,19 +758,23 @@ const McpServersTab = () => {
{t('mcp.installed.empty')}
</div>
)}
{activeChip === 'registry' && availableCatalog.length === 0 && !catalogLoading && (
<div
data-testid="mcp-catalog-empty"
className="py-8 text-center text-sm text-content-faint">
{searchQuery
? t('mcp.catalog.noResultsFor').replace('{query}', searchQuery)
: t('mcp.catalog.noResults')}
</div>
)}
{activeChip === 'registry' &&
availableCatalog.length === 0 &&
!catalogLoading &&
!catalogError && (
<div
data-testid="mcp-catalog-empty"
className="py-8 text-center text-sm text-content-faint">
{searchQuery
? t('mcp.catalog.noResultsFor').replace('{query}', searchQuery)
: t('mcp.catalog.noResults')}
</div>
)}
{activeChip === 'all' &&
filteredInstalled.length === 0 &&
availableCatalog.length === 0 &&
!catalogLoading && (
!catalogLoading &&
!catalogError && (
<div
data-testid="mcp-catalog-empty"
className="py-8 text-center text-sm text-content-faint">
+12
View File
@@ -23,6 +23,18 @@ export type SmitheryServer = {
* hidden. Stamped by the Rust dispatcher; never trusted from the wire.
*/
official?: boolean;
/**
* Vendor/site URL declared by the server, when present. The strict catalog
* filter requires it, so every listed row carries one; rendered as a
* clickable external link.
*/
website_url?: string;
/**
* Declared auth method from registry metadata. `'api_key'` means the server
* declares a named static secret (header/env). The strict filter only lists
* `'api_key'` servers, so connecting never depends on a probe guess.
*/
auth_kind?: 'api_key' | string;
};
export type SmitheryConnection = {
+7 -8
View File
@@ -1356,6 +1356,7 @@ const messages: TranslationMap = {
'mcp.catalog.loadMore': 'تحميل المزيد',
'mcp.configAssistant.title': 'مساعد التكوين',
'mcp.configAssistant.empty': 'اسأل عن التكوين أو متغيرات env المطلوبة أو خطوات الإعداد.',
'mcp.configAssistant.autoPromptCta': 'احصل على مساعدة الإعداد خطوة بخطوة',
'mcp.configAssistant.suggestedValues': 'القيم المقترحة:',
'mcp.configAssistant.valueHidden': '(القيمة مخفية)',
'mcp.configAssistant.applySuggested': 'تطبيق القيم المقترحة',
@@ -1495,13 +1496,15 @@ const messages: TranslationMap = {
'mcp.tab.filter.registry': 'السجل',
'mcp.tab.column.name': 'الاسم',
'mcp.tab.column.description': 'الوصف',
'mcp.tab.column.source': 'المصدر',
'mcp.tab.column.type': 'النوع',
'mcp.tab.column.author': 'المؤلف',
'mcp.tab.column.action': 'الإجراء',
'mcp.tab.source.official': 'رسمي',
'mcp.tab.source.smithery': 'Smithery',
'mcp.tab.transport.hosted': 'مستضاف',
'mcp.tab.transport.local': 'محلي',
'mcp.tab.transport.local': 'Stdio',
'mcp.tab.transportFilter.label': 'النوع',
'mcp.tab.transportFilter.aria': 'تصفية الخوادم حسب وسيلة النقل',
'mcp.tab.link.website': 'الموقع',
'mcp.tab.link.repo': 'المستودع',
'mcp.tab.transport.hostedHint': 'يعمل على خادم بعيد — يُعدّ تسجيل الدخول أو الرمز عند التثبيت',
'mcp.tab.transport.localHint': 'يعمل على جهازك — قد يحتاج إلى رمز عند التثبيت',
'mcp.tab.officialBadge': 'رسمي',
@@ -1527,11 +1530,7 @@ const messages: TranslationMap = {
'mcp.install.button': 'تثبيت',
'mcp.install.installing': 'جارٍ التثبيت...',
'mcp.install.by': 'بواسطة',
'mcp.install.transportLocal': 'يعمل محليًا',
'mcp.install.transportRemote': 'مستضاف سحابيًا',
'mcp.install.useCount': 'عمليات تثبيت {count}',
'mcp.install.deployed': 'منشور',
'mcp.install.requiresConfig': 'يتطلب تكوينًا',
'mcp.install.connections': 'الاتصالات المتاحة',
'mcp.install.published': 'منشور',
'mcp.install.configureAndInstall': 'تكوين وتثبيت',
+7 -8
View File
@@ -1378,6 +1378,7 @@ const messages: TranslationMap = {
'mcp.catalog.loadMore': 'আরও লোড করুন',
'mcp.configAssistant.title': 'কনফিগারেশন সহকারী',
'mcp.configAssistant.empty': 'কনফিগারেশন জানার জন্য, বিবিধ বৈশিষ্ট্য অথবা প্রস্তুতির প্রয়োজন।',
'mcp.configAssistant.autoPromptCta': 'ধাপে ধাপে সেটআপ সহায়তা নিন',
'mcp.configAssistant.suggestedValues': 'প্রস্তাবিত মান:',
'mcp.configAssistant.valueHidden': '(মান লুকানো)',
'mcp.configAssistant.applySuggested': 'প্রস্তাবিত মান প্রয়োগ করুন',
@@ -1523,13 +1524,15 @@ const messages: TranslationMap = {
'mcp.tab.filter.registry': 'রেজিস্ট্রি',
'mcp.tab.column.name': 'নাম',
'mcp.tab.column.description': 'বিবরণ',
'mcp.tab.column.source': 'উৎস',
'mcp.tab.column.type': 'ধরন',
'mcp.tab.column.author': 'লেখক',
'mcp.tab.column.action': 'ক্রিয়া',
'mcp.tab.source.official': 'অফিসিয়াল',
'mcp.tab.source.smithery': 'Smithery',
'mcp.tab.transport.hosted': 'হোস্টেড',
'mcp.tab.transport.local': 'লোকাল',
'mcp.tab.transport.local': 'Stdio',
'mcp.tab.transportFilter.label': 'ধরন',
'mcp.tab.transportFilter.aria': 'ট্রান্সপোর্ট অনুযায়ী সার্ভার ফিল্টার করুন',
'mcp.tab.link.website': 'ওয়েবসাইট',
'mcp.tab.link.repo': 'রিপোজিটরি',
'mcp.tab.transport.hostedHint':
'একটি দূরবর্তী সার্ভারে চলে — ইনস্টলের সময় সাইন-ইন বা টোকেন সেট করা হয়',
'mcp.tab.transport.localHint': 'আপনার ডিভাইসে চলে — ইনস্টলের সময় টোকেন লাগতে পারে',
@@ -1556,11 +1559,7 @@ const messages: TranslationMap = {
'mcp.install.button': 'ইনস্টল করুন',
'mcp.install.installing': 'ইনস্টল করা হচ্ছে...',
'mcp.install.by': 'দ্বারা',
'mcp.install.transportLocal': 'স্থানীয়ভাবে চলে',
'mcp.install.transportRemote': 'ক্লাউডে হোস্ট করা',
'mcp.install.useCount': '{count} ইনস্টলেশন',
'mcp.install.deployed': 'স্থাপিত',
'mcp.install.requiresConfig': 'কনফিগারেশন প্রয়োজন',
'mcp.install.connections': 'উপলব্ধ সংযোগ',
'mcp.install.published': 'প্রকাশিত',
'mcp.install.configureAndInstall': 'কনফিগার করুন ও ইনস্টল করুন',
+7 -8
View File
@@ -1416,6 +1416,7 @@ const messages: TranslationMap = {
'mcp.configAssistant.title': 'Konfigurationsassistent',
'mcp.configAssistant.empty':
'Fragen Sie nach Konfiguration, erforderlichen Umgebungsvariablen oder Einrichtungsschritten.',
'mcp.configAssistant.autoPromptCta': 'Schritt-für-Schritt-Einrichtungshilfe',
'mcp.configAssistant.suggestedValues': 'Vorgeschlagene Werte:',
'mcp.configAssistant.valueHidden': '(Wert ausgeblendet)',
'mcp.configAssistant.applySuggested': 'Vorgeschlagene Werte anwenden',
@@ -1565,13 +1566,15 @@ const messages: TranslationMap = {
'mcp.tab.filter.registry': 'Registrierung',
'mcp.tab.column.name': 'Name',
'mcp.tab.column.description': 'Beschreibung',
'mcp.tab.column.source': 'Quelle',
'mcp.tab.column.type': 'Typ',
'mcp.tab.column.author': 'Autor',
'mcp.tab.column.action': 'Aktion',
'mcp.tab.source.official': 'Offiziell',
'mcp.tab.source.smithery': 'Smithery',
'mcp.tab.transport.hosted': 'Gehostet',
'mcp.tab.transport.local': 'Lokal',
'mcp.tab.transport.local': 'Stdio',
'mcp.tab.transportFilter.label': 'Typ',
'mcp.tab.transportFilter.aria': 'Server nach Transport filtern',
'mcp.tab.link.website': 'Website',
'mcp.tab.link.repo': 'Repository',
'mcp.tab.transport.hostedHint':
'Läuft auf einem entfernten Server Anmeldung oder Token wird bei der Installation eingerichtet',
'mcp.tab.transport.localHint':
@@ -1599,11 +1602,7 @@ const messages: TranslationMap = {
'mcp.install.button': 'Installation',
'mcp.install.installing': 'Installation...',
'mcp.install.by': 'von',
'mcp.install.transportLocal': 'Lokal ausgeführt',
'mcp.install.transportRemote': 'Cloud-gehostet',
'mcp.install.useCount': '{count} Installationen',
'mcp.install.deployed': 'Bereitgestellt',
'mcp.install.requiresConfig': 'Konfiguration erforderlich',
'mcp.install.connections': 'Verfügbare Verbindungen',
'mcp.install.published': 'veröffentlicht',
'mcp.install.configureAndInstall': 'Konfigurieren & installieren',
+7 -8
View File
@@ -1729,6 +1729,7 @@ const en: TranslationMap = {
'mcp.catalog.loadMore': 'Load more',
'mcp.configAssistant.title': 'Configuration assistant',
'mcp.configAssistant.empty': 'Ask about configuration, required env vars, or setup steps.',
'mcp.configAssistant.autoPromptCta': 'Get step-by-step setup help',
'mcp.configAssistant.suggestedValues': 'Suggested values:',
'mcp.configAssistant.valueHidden': '(value hidden)',
'mcp.configAssistant.applySuggested': 'Apply suggested values',
@@ -1872,16 +1873,18 @@ const en: TranslationMap = {
'mcp.tab.filter.registry': 'Registry',
'mcp.tab.column.name': 'Name',
'mcp.tab.column.description': 'Description',
'mcp.tab.column.source': 'Source',
'mcp.tab.column.type': 'Type',
'mcp.tab.column.author': 'Author',
'mcp.tab.column.action': 'Action',
'mcp.tab.source.official': 'Official',
'mcp.tab.source.smithery': 'Smithery',
'mcp.tab.transport.hosted': 'Hosted',
'mcp.tab.transport.local': 'Local',
'mcp.tab.transport.local': 'Stdio',
'mcp.tab.transport.hostedHint':
'Runs on a remote server — sign-in or token is set up when you install',
'mcp.tab.transport.localHint': 'Runs on your device — may need a token when you install',
'mcp.tab.transportFilter.label': 'Type',
'mcp.tab.transportFilter.aria': 'Filter servers by transport',
'mcp.tab.link.website': 'Website',
'mcp.tab.link.repo': 'Repository',
'mcp.tab.officialBadge': 'Official',
'mcp.tab.officialHint': 'Official server from the vendor',
'mcp.tab.badge.installed': 'Installed',
@@ -1905,11 +1908,7 @@ const en: TranslationMap = {
'mcp.install.button': 'Install',
'mcp.install.installing': 'Installing...',
'mcp.install.by': 'by',
'mcp.install.transportLocal': 'Runs locally',
'mcp.install.transportRemote': 'Cloud hosted',
'mcp.install.useCount': '{count} installs',
'mcp.install.deployed': 'Deployed',
'mcp.install.requiresConfig': 'Requires configuration',
'mcp.install.connections': 'Available connections',
'mcp.install.published': 'published',
'mcp.install.configureAndInstall': 'Configure & install',
+7 -8
View File
@@ -1407,6 +1407,7 @@ const messages: TranslationMap = {
'mcp.configAssistant.title': 'Asistente de configuración',
'mcp.configAssistant.empty':
'Pregunte sobre la configuración, las variables de entorno requeridas o los pasos de configuración.',
'mcp.configAssistant.autoPromptCta': 'Obtener ayuda de configuración paso a paso',
'mcp.configAssistant.suggestedValues': 'Valores sugeridos:',
'mcp.configAssistant.valueHidden': '(valor oculto)',
'mcp.configAssistant.applySuggested': 'Aplicar valores sugeridos',
@@ -1557,13 +1558,15 @@ const messages: TranslationMap = {
'mcp.tab.filter.registry': 'Registro',
'mcp.tab.column.name': 'Nombre',
'mcp.tab.column.description': 'Descripción',
'mcp.tab.column.source': 'Origen',
'mcp.tab.column.type': 'Tipo',
'mcp.tab.column.author': 'Autor',
'mcp.tab.column.action': 'Acción',
'mcp.tab.source.official': 'Oficial',
'mcp.tab.source.smithery': 'Smithery',
'mcp.tab.transport.hosted': 'Alojado',
'mcp.tab.transport.local': 'Local',
'mcp.tab.transport.local': 'Stdio',
'mcp.tab.transportFilter.label': 'Tipo',
'mcp.tab.transportFilter.aria': 'Filtrar servidores por transporte',
'mcp.tab.link.website': 'Sitio web',
'mcp.tab.link.repo': 'Repositorio',
'mcp.tab.transport.hostedHint':
'Se ejecuta en un servidor remoto: el inicio de sesión o el token se configura al instalar',
'mcp.tab.transport.localHint':
@@ -1591,11 +1594,7 @@ const messages: TranslationMap = {
'mcp.install.button': 'Instalar',
'mcp.install.installing': 'Instalando...',
'mcp.install.by': 'por',
'mcp.install.transportLocal': 'Se ejecuta localmente',
'mcp.install.transportRemote': 'Alojado en la nube',
'mcp.install.useCount': '{count} instalaciones',
'mcp.install.deployed': 'Desplegado',
'mcp.install.requiresConfig': 'Requiere configuración',
'mcp.install.connections': 'Conexiones disponibles',
'mcp.install.published': 'publicado',
'mcp.install.configureAndInstall': 'Configurar e instalar',
+7 -8
View File
@@ -1417,6 +1417,7 @@ const messages: TranslationMap = {
'mcp.configAssistant.title': 'Assistant de configuration',
'mcp.configAssistant.empty':
"Demandez à propos de la configuration, des variables d'environnement requises ou des étapes de configuration.",
'mcp.configAssistant.autoPromptCta': 'Obtenir une aide à la configuration étape par étape',
'mcp.configAssistant.suggestedValues': 'Valeurs suggérées :',
'mcp.configAssistant.valueHidden': '(valeur masquée)',
'mcp.configAssistant.applySuggested': 'Appliquer les valeurs suggérées',
@@ -1566,13 +1567,15 @@ const messages: TranslationMap = {
'mcp.tab.filter.registry': 'Registre',
'mcp.tab.column.name': 'Nom',
'mcp.tab.column.description': 'Description',
'mcp.tab.column.source': 'Source',
'mcp.tab.column.type': 'Type',
'mcp.tab.column.author': 'Auteur',
'mcp.tab.column.action': 'Action',
'mcp.tab.source.official': 'Officiel',
'mcp.tab.source.smithery': 'Smithery',
'mcp.tab.transport.hosted': 'Hébergé',
'mcp.tab.transport.local': 'Local',
'mcp.tab.transport.local': 'Stdio',
'mcp.tab.transportFilter.label': 'Type',
'mcp.tab.transportFilter.aria': 'Filtrer les serveurs par transport',
'mcp.tab.link.website': 'Site web',
'mcp.tab.link.repo': 'Dépôt',
'mcp.tab.transport.hostedHint':
"S'exécute sur un serveur distant — la connexion ou le jeton est configuré lors de l'installation",
'mcp.tab.transport.localHint':
@@ -1600,11 +1603,7 @@ const messages: TranslationMap = {
'mcp.install.button': 'Installation',
'mcp.install.installing': 'Installation...',
'mcp.install.by': 'par',
'mcp.install.transportLocal': 'Exécution locale',
'mcp.install.transportRemote': 'Hébergé dans le cloud',
'mcp.install.useCount': '{count} installations',
'mcp.install.deployed': 'Déployé',
'mcp.install.requiresConfig': 'Configuration requise',
'mcp.install.connections': 'Connexions disponibles',
'mcp.install.published': 'publié',
'mcp.install.configureAndInstall': 'Configurer et installer',
+7 -8
View File
@@ -1376,6 +1376,7 @@ const messages: TranslationMap = {
'mcp.catalog.loadMore': 'और अधिक लोड करें',
'mcp.configAssistant.title': 'कॉन्फ़िगरेशन सहायक',
'mcp.configAssistant.empty': 'कॉन्फ़िगरेशन, आवश्यक एनवी वर्र्स या सेटअप चरणों के बारे में पूछें।',
'mcp.configAssistant.autoPromptCta': 'चरण-दर-चरण सेटअप सहायता प्राप्त करें',
'mcp.configAssistant.suggestedValues': 'सुझाए गए मान:',
'mcp.configAssistant.valueHidden': '(मूल्य छिपा हुआ)',
'mcp.configAssistant.applySuggested': 'सुझाए गए मान लागू करें',
@@ -1521,13 +1522,15 @@ const messages: TranslationMap = {
'mcp.tab.filter.registry': 'रजिस्ट्री',
'mcp.tab.column.name': 'नाम',
'mcp.tab.column.description': 'विवरण',
'mcp.tab.column.source': '्रोत',
'mcp.tab.column.type': '्रकार',
'mcp.tab.column.author': 'लेखक',
'mcp.tab.column.action': 'क्रिया',
'mcp.tab.source.official': 'आधिकारिक',
'mcp.tab.source.smithery': 'Smithery',
'mcp.tab.transport.hosted': 'होस्टेड',
'mcp.tab.transport.local': 'लोकल',
'mcp.tab.transport.local': 'Stdio',
'mcp.tab.transportFilter.label': 'प्रकार',
'mcp.tab.transportFilter.aria': 'ट्रांसपोर्ट के अनुसार सर्वर फ़िल्टर करें',
'mcp.tab.link.website': 'वेबसाइट',
'mcp.tab.link.repo': 'रिपॉज़िटरी',
'mcp.tab.transport.hostedHint':
'दूरस्थ सर्वर पर चलता है — इंस्टॉल करते समय साइन-इन या टोकन सेट किया जाता है',
'mcp.tab.transport.localHint':
@@ -1555,11 +1558,7 @@ const messages: TranslationMap = {
'mcp.install.button': 'स्थापित करें',
'mcp.install.installing': 'स्थापित किया जा रहा है...',
'mcp.install.by': 'द्वारा',
'mcp.install.transportLocal': 'स्थानीय रूप से चलता है',
'mcp.install.transportRemote': 'क्लाउड होस्टेड',
'mcp.install.useCount': '{count} इंस्टॉलेशन',
'mcp.install.deployed': 'तैनात',
'mcp.install.requiresConfig': 'कॉन्फ़िगरेशन आवश्यक',
'mcp.install.connections': 'उपलब्ध कनेक्शन',
'mcp.install.published': 'प्रकाशित',
'mcp.install.configureAndInstall': 'कॉन्फ़िगर करें और इंस्टॉल करें',
+7 -8
View File
@@ -1388,6 +1388,7 @@ const messages: TranslationMap = {
'mcp.configAssistant.title': 'Asisten konfigurasi',
'mcp.configAssistant.empty':
'Tanyakan tentang konfigurasi, env vars yang diperlukan, atau langkah setup.',
'mcp.configAssistant.autoPromptCta': 'Dapatkan bantuan penyiapan langkah demi langkah',
'mcp.configAssistant.suggestedValues': 'Nilai yang disarankan:',
'mcp.configAssistant.valueHidden': '(nilai tersembunyi)',
'mcp.configAssistant.applySuggested': 'Terapkan nilai yang disarankan',
@@ -1532,13 +1533,15 @@ const messages: TranslationMap = {
'mcp.tab.filter.registry': 'Registri',
'mcp.tab.column.name': 'Nama',
'mcp.tab.column.description': 'Deskripsi',
'mcp.tab.column.source': 'Sumber',
'mcp.tab.column.type': 'Tipe',
'mcp.tab.column.author': 'Penulis',
'mcp.tab.column.action': 'Aksi',
'mcp.tab.source.official': 'Resmi',
'mcp.tab.source.smithery': 'Smithery',
'mcp.tab.transport.hosted': 'Terhosting',
'mcp.tab.transport.local': 'Lokal',
'mcp.tab.transport.local': 'Stdio',
'mcp.tab.transportFilter.label': 'Tipe',
'mcp.tab.transportFilter.aria': 'Filter server berdasarkan transport',
'mcp.tab.link.website': 'Situs web',
'mcp.tab.link.repo': 'Repositori',
'mcp.tab.transport.hostedHint':
'Berjalan di server jarak jauh — masuk atau token diatur saat memasang',
'mcp.tab.transport.localHint': 'Berjalan di perangkat Anda — mungkin perlu token saat memasang',
@@ -1565,11 +1568,7 @@ const messages: TranslationMap = {
'mcp.install.button': 'Penginstalan',
'mcp.install.installing': 'Penginstalan...',
'mcp.install.by': 'oleh',
'mcp.install.transportLocal': 'Berjalan secara lokal',
'mcp.install.transportRemote': 'Di-host di cloud',
'mcp.install.useCount': '{count} instalasi',
'mcp.install.deployed': 'Diterapkan',
'mcp.install.requiresConfig': 'Memerlukan konfigurasi',
'mcp.install.connections': 'Koneksi tersedia',
'mcp.install.published': 'diterbitkan',
'mcp.install.configureAndInstall': 'Konfigurasi & instal',
+7 -8
View File
@@ -1408,6 +1408,7 @@ const messages: TranslationMap = {
'mcp.configAssistant.title': 'Assistente di configurazione',
'mcp.configAssistant.empty':
"Chiedi della configurazione, delle variabili d'ambiente richieste o dei passaggi di configurazione.",
'mcp.configAssistant.autoPromptCta': 'Ottieni aiuto alla configurazione passo passo',
'mcp.configAssistant.suggestedValues': 'Valori suggeriti:',
'mcp.configAssistant.valueHidden': '(valore nascosto)',
'mcp.configAssistant.applySuggested': 'Applica i valori suggeriti',
@@ -1556,13 +1557,15 @@ const messages: TranslationMap = {
'mcp.tab.filter.registry': 'Registro',
'mcp.tab.column.name': 'Nome',
'mcp.tab.column.description': 'Descrizione',
'mcp.tab.column.source': 'Origine',
'mcp.tab.column.type': 'Tipo',
'mcp.tab.column.author': 'Autore',
'mcp.tab.column.action': 'Azione',
'mcp.tab.source.official': 'Ufficiale',
'mcp.tab.source.smithery': 'Smithery',
'mcp.tab.transport.hosted': 'Ospitato',
'mcp.tab.transport.local': 'Locale',
'mcp.tab.transport.local': 'Stdio',
'mcp.tab.transportFilter.label': 'Tipo',
'mcp.tab.transportFilter.aria': 'Filtra i server per trasporto',
'mcp.tab.link.website': 'Sito web',
'mcp.tab.link.repo': 'Repository',
'mcp.tab.transport.hostedHint':
"Funziona su un server remoto: l'accesso o il token si configura durante l'installazione",
'mcp.tab.transport.localHint':
@@ -1590,11 +1593,7 @@ const messages: TranslationMap = {
'mcp.install.button': 'Installa',
'mcp.install.installing': 'Installazione...',
'mcp.install.by': 'di',
'mcp.install.transportLocal': 'Esecuzione locale',
'mcp.install.transportRemote': 'Ospitato nel cloud',
'mcp.install.useCount': '{count} installazioni',
'mcp.install.deployed': 'Distribuito',
'mcp.install.requiresConfig': 'Configurazione richiesta',
'mcp.install.connections': 'Connessioni disponibili',
'mcp.install.published': 'pubblicato',
'mcp.install.configureAndInstall': 'Configura e installa',
+7 -8
View File
@@ -1374,6 +1374,7 @@ const messages: TranslationMap = {
'mcp.catalog.loadMore': '추가 로드',
'mcp.configAssistant.title': '구성 도우미',
'mcp.configAssistant.empty': '구성, 필수 환경 변수 또는 설정 단계에 대해 문의하세요.',
'mcp.configAssistant.autoPromptCta': '단계별 설정 도움말 보기',
'mcp.configAssistant.suggestedValues': '제안 값:',
'mcp.configAssistant.valueHidden': '(값 숨김)',
'mcp.configAssistant.applySuggested': '제안 값 적용',
@@ -1516,13 +1517,15 @@ const messages: TranslationMap = {
'mcp.tab.filter.registry': '레지스트리',
'mcp.tab.column.name': '이름',
'mcp.tab.column.description': '설명',
'mcp.tab.column.source': '출처',
'mcp.tab.column.type': '유형',
'mcp.tab.column.author': '작성자',
'mcp.tab.column.action': '작업',
'mcp.tab.source.official': '공식',
'mcp.tab.source.smithery': 'Smithery',
'mcp.tab.transport.hosted': '호스팅됨',
'mcp.tab.transport.local': '로컬',
'mcp.tab.transport.local': 'Stdio',
'mcp.tab.transportFilter.label': '유형',
'mcp.tab.transportFilter.aria': '전송 방식으로 서버 필터링',
'mcp.tab.link.website': '웹사이트',
'mcp.tab.link.repo': '저장소',
'mcp.tab.transport.hostedHint': '원격 서버에서 실행 — 설치 시 로그인 또는 토큰을 설정합니다',
'mcp.tab.transport.localHint': '기기에서 실행 — 설치 시 토큰이 필요할 수 있습니다',
'mcp.tab.officialBadge': '공식',
@@ -1548,11 +1551,7 @@ const messages: TranslationMap = {
'mcp.install.button': '설치',
'mcp.install.installing': '설치 중...',
'mcp.install.by': '제작',
'mcp.install.transportLocal': '로컬 실행',
'mcp.install.transportRemote': '클라우드 호스팅',
'mcp.install.useCount': '{count}회 설치',
'mcp.install.deployed': '배포됨',
'mcp.install.requiresConfig': '구성 필요',
'mcp.install.connections': '사용 가능한 연결',
'mcp.install.published': '게시됨',
'mcp.install.configureAndInstall': '구성 및 설치',
+7 -8
View File
@@ -1396,6 +1396,7 @@ const messages: TranslationMap = {
'mcp.catalog.loadMore': 'Załaduj więcej',
'mcp.configAssistant.title': 'Asystent konfiguracji',
'mcp.configAssistant.empty': 'Zapytaj o konfigurację, wymagane zmienne lub kroki instalacji.',
'mcp.configAssistant.autoPromptCta': 'Uzyskaj pomoc w konfiguracji krok po kroku',
'mcp.configAssistant.suggestedValues': 'Sugerowane wartości:',
'mcp.configAssistant.valueHidden': '(wartość ukryta)',
'mcp.configAssistant.applySuggested': 'Zastosuj sugerowane wartości',
@@ -1544,13 +1545,15 @@ const messages: TranslationMap = {
'mcp.tab.filter.registry': 'Rejestr',
'mcp.tab.column.name': 'Nazwa',
'mcp.tab.column.description': 'Opis',
'mcp.tab.column.source': 'Źródło',
'mcp.tab.column.type': 'Typ',
'mcp.tab.column.author': 'Autor',
'mcp.tab.column.action': 'Akcja',
'mcp.tab.source.official': 'Oficjalny',
'mcp.tab.source.smithery': 'Smithery',
'mcp.tab.transport.hosted': 'Hostowany',
'mcp.tab.transport.local': 'Lokalny',
'mcp.tab.transport.local': 'Stdio',
'mcp.tab.transportFilter.label': 'Typ',
'mcp.tab.transportFilter.aria': 'Filtruj serwery według transportu',
'mcp.tab.link.website': 'Strona',
'mcp.tab.link.repo': 'Repozytorium',
'mcp.tab.transport.hostedHint':
'Działa na zdalnym serwerze — logowanie lub token jest konfigurowany przy instalacji',
'mcp.tab.transport.localHint': 'Działa na Twoim urządzeniu — może wymagać tokena przy instalacji',
@@ -1577,11 +1580,7 @@ const messages: TranslationMap = {
'mcp.install.button': 'Zainstaluj',
'mcp.install.installing': 'Instalowanie...',
'mcp.install.by': 'autor:',
'mcp.install.transportLocal': 'Uruchamiane lokalnie',
'mcp.install.transportRemote': 'Hostowane w chmurze',
'mcp.install.useCount': '{count} instalacji',
'mcp.install.deployed': 'Wdrożone',
'mcp.install.requiresConfig': 'Wymaga konfiguracji',
'mcp.install.connections': 'Dostępne połączenia',
'mcp.install.published': 'opublikowane',
'mcp.install.configureAndInstall': 'Skonfiguruj i zainstaluj',
+7 -8
View File
@@ -1412,6 +1412,7 @@ const messages: TranslationMap = {
'mcp.configAssistant.title': 'Assistente de configuração',
'mcp.configAssistant.empty':
'Pergunte sobre configuração, variáveis de ambiente necessárias ou etapas de configuração.',
'mcp.configAssistant.autoPromptCta': 'Obter ajuda de configuração passo a passo',
'mcp.configAssistant.suggestedValues': 'Valores sugeridos:',
'mcp.configAssistant.valueHidden': '(valor oculto)',
'mcp.configAssistant.applySuggested': 'Aplicar valores sugeridos',
@@ -1561,13 +1562,15 @@ const messages: TranslationMap = {
'mcp.tab.filter.registry': 'Registro',
'mcp.tab.column.name': 'Nome',
'mcp.tab.column.description': 'Descrição',
'mcp.tab.column.source': 'Origem',
'mcp.tab.column.type': 'Tipo',
'mcp.tab.column.author': 'Autor',
'mcp.tab.column.action': 'Ação',
'mcp.tab.source.official': 'Oficial',
'mcp.tab.source.smithery': 'Smithery',
'mcp.tab.transport.hosted': 'Hospedado',
'mcp.tab.transport.local': 'Local',
'mcp.tab.transport.local': 'Stdio',
'mcp.tab.transportFilter.label': 'Tipo',
'mcp.tab.transportFilter.aria': 'Filtrar servidores por transporte',
'mcp.tab.link.website': 'Site',
'mcp.tab.link.repo': 'Repositório',
'mcp.tab.transport.hostedHint':
'Executa em um servidor remoto — login ou token é configurado ao instalar',
'mcp.tab.transport.localHint':
@@ -1595,11 +1598,7 @@ const messages: TranslationMap = {
'mcp.install.button': 'Instalação',
'mcp.install.installing': 'Instalando...',
'mcp.install.by': 'por',
'mcp.install.transportLocal': 'Execução local',
'mcp.install.transportRemote': 'Hospedado na nuvem',
'mcp.install.useCount': '{count} instalações',
'mcp.install.deployed': 'Implantado',
'mcp.install.requiresConfig': 'Requer configuração',
'mcp.install.connections': 'Conexões disponíveis',
'mcp.install.published': 'publicado',
'mcp.install.configureAndInstall': 'Configurar e instalar',
+7 -8
View File
@@ -1396,6 +1396,7 @@ const messages: TranslationMap = {
'mcp.configAssistant.title': 'Помощник по настройке',
'mcp.configAssistant.empty':
'Спросите о конфигурации, необходимых переменных окружения или этапах настройки.',
'mcp.configAssistant.autoPromptCta': 'Пошаговая помощь по настройке',
'mcp.configAssistant.suggestedValues': 'Рекомендуемые значения:',
'mcp.configAssistant.valueHidden': '(значение скрыто)',
'mcp.configAssistant.applySuggested': 'Примените предложенные значения.',
@@ -1541,13 +1542,15 @@ const messages: TranslationMap = {
'mcp.tab.filter.registry': 'Реестр',
'mcp.tab.column.name': 'Название',
'mcp.tab.column.description': 'Описание',
'mcp.tab.column.source': 'Источник',
'mcp.tab.column.type': 'Тип',
'mcp.tab.column.author': 'Автор',
'mcp.tab.column.action': 'Действие',
'mcp.tab.source.official': 'Официальный',
'mcp.tab.source.smithery': 'Smithery',
'mcp.tab.transport.hosted': 'Размещённый',
'mcp.tab.transport.local': 'Локальный',
'mcp.tab.transport.local': 'Stdio',
'mcp.tab.transportFilter.label': 'Тип',
'mcp.tab.transportFilter.aria': 'Фильтр серверов по транспорту',
'mcp.tab.link.website': 'Сайт',
'mcp.tab.link.repo': 'Репозиторий',
'mcp.tab.transport.hostedHint':
'Работает на удалённом сервере — вход или токен настраивается при установке',
'mcp.tab.transport.localHint':
@@ -1575,11 +1578,7 @@ const messages: TranslationMap = {
'mcp.install.button': 'Установить',
'mcp.install.installing': 'Установка...',
'mcp.install.by': 'от',
'mcp.install.transportLocal': 'Локальный запуск',
'mcp.install.transportRemote': 'Облачный хостинг',
'mcp.install.useCount': '{count} установок',
'mcp.install.deployed': 'Развёрнуто',
'mcp.install.requiresConfig': 'Требуется настройка',
'mcp.install.connections': 'Доступные подключения',
'mcp.install.published': 'опубликовано',
'mcp.install.configureAndInstall': 'Настроить и установить',
+7 -8
View File
@@ -1313,6 +1313,7 @@ const messages: TranslationMap = {
'mcp.catalog.loadMore': '加载更多',
'mcp.configAssistant.title': '配置助手',
'mcp.configAssistant.empty': '询问配置、所需的环境变量或设置步骤。',
'mcp.configAssistant.autoPromptCta': '获取分步设置帮助',
'mcp.configAssistant.suggestedValues': '建议值:',
'mcp.configAssistant.valueHidden': '(隐藏值)',
'mcp.configAssistant.applySuggested': '应用建议值',
@@ -1452,13 +1453,15 @@ const messages: TranslationMap = {
'mcp.tab.filter.registry': '注册表',
'mcp.tab.column.name': '名称',
'mcp.tab.column.description': '描述',
'mcp.tab.column.source': '来源',
'mcp.tab.column.type': '类型',
'mcp.tab.column.author': '作者',
'mcp.tab.column.action': '操作',
'mcp.tab.source.official': '官方',
'mcp.tab.source.smithery': 'Smithery',
'mcp.tab.transport.hosted': '托管',
'mcp.tab.transport.local': '本地',
'mcp.tab.transport.local': 'Stdio',
'mcp.tab.transportFilter.label': '类型',
'mcp.tab.transportFilter.aria': '按传输方式筛选服务器',
'mcp.tab.link.website': '网站',
'mcp.tab.link.repo': '仓库',
'mcp.tab.transport.hostedHint': '在远程服务器上运行 — 安装时设置登录或令牌',
'mcp.tab.transport.localHint': '在您的设备上运行 — 安装时可能需要令牌',
'mcp.tab.officialBadge': '官方',
@@ -1484,11 +1487,7 @@ const messages: TranslationMap = {
'mcp.install.button': '安装',
'mcp.install.installing': '正在安装...',
'mcp.install.by': '作者',
'mcp.install.transportLocal': '本地运行',
'mcp.install.transportRemote': '云端托管',
'mcp.install.useCount': '{count} 次安装',
'mcp.install.deployed': '已部署',
'mcp.install.requiresConfig': '需要配置',
'mcp.install.connections': '可用连接',
'mcp.install.published': '已发布',
'mcp.install.configureAndInstall': '配置并安装',
+2
View File
@@ -107,6 +107,8 @@ export const mcpClientsApi = {
/** Search the Smithery registry. Returns paged results. */
registrySearch: async (params: {
query?: string;
/** Transport filter: 'stdio' | 'hosted' | 'all' (omit for all). */
transport?: string;
page?: number;
page_size?: number;
}): Promise<RegistrySearchResult> => {
+114 -10
View File
@@ -75,6 +75,13 @@ const STATUS_CONNECTED = {
tool_count: 5,
};
// Tools a connected server exposes — returned by the mocked connect so the
// detail's tool list (and the execution playground) have something to drive.
const MOCK_TOOLS = [
{ name: 'create_memory', description: 'Create a memory', input_schema: {} },
{ name: 'list_memories', description: 'List all memories', input_schema: {} },
];
const GITHUB_DETAIL = {
...REGISTRY_SERVERS[1],
connections: [{ type: 'stdio', published: true }],
@@ -100,7 +107,9 @@ const GITHUB_INSTALLED = {
interface MockState {
installed: (typeof INSTALLED_DEFAULT)[];
statuses: (typeof STATUS_CONNECTED)[];
// `status` is broadened to a string so tests can seed non-connected states
// (e.g. `error`) that keep the status poll active.
statuses: Array<Omit<typeof STATUS_CONNECTED, 'status'> & { status: string }>;
}
function rpcOk(id: number, result: unknown) {
@@ -197,15 +206,47 @@ async function setupMockRpc(page: Page, state: MockState) {
state.installed.push(GITHUB_INSTALLED);
return route.fulfill(rpcOk(id, { server: GITHUB_INSTALLED }));
case 'openhuman.mcp_clients_connect':
state.statuses.push({
server_id: params.server_id,
qualified_name: 'io.github.test/github-tools',
display_name: 'GitHub Tools',
status: 'connected',
tool_count: 3,
});
return route.fulfill(rpcOk(id, { status: 'connected', tools: [] }));
// Auth probe for the upfront connect modal — these test servers need no
// credentials, so report `none` and the modal shows a single Connect button.
case 'openhuman.mcp_clients_detect_auth':
return route.fulfill(rpcOk(id, { kind: 'none' }));
case 'openhuman.mcp_clients_connect': {
const sid = params.server_id;
const inst = state.installed.find(s => s.server_id === sid);
// Reject unknown server ids so the test can't pass while wired to the
// wrong server.
if (!inst) {
return route.fulfill(rpcError(id, `server not installed: ${sid}`));
}
// Mark the server connected for subsequent status polls and hand back
// its tool list (what `onConnected` feeds into the detail's tool list).
state.statuses = [
...state.statuses.filter(s => s.server_id !== sid),
{
server_id: sid,
qualified_name: inst.qualified_name,
display_name: inst.display_name,
status: 'connected',
tool_count: MOCK_TOOLS.length,
},
];
return route.fulfill(rpcOk(id, { status: 'connected', tools: MOCK_TOOLS }));
}
// Tool execution — what the playground's "Run tool" calls. Unknown tools
// come back as a tool error so the spec can't pass on a wrong tool name.
case 'openhuman.mcp_clients_tool_call': {
const known = MOCK_TOOLS.some(t => t.name === params.tool_name);
if (!known) {
return route.fulfill(
rpcOk(id, { result: `unknown tool: ${params.tool_name}`, is_error: true })
);
}
return route.fulfill(
rpcOk(id, { result: `ran ${params.tool_name}: memory created id=42`, is_error: false })
);
}
case 'openhuman.mcp_clients_disconnect':
state.statuses = state.statuses.filter(s => s.server_id !== params.server_id);
@@ -470,6 +511,69 @@ test.describe('MCP Tab — Manage & Uninstall Lifecycle', () => {
});
});
test.describe('MCP Tab — Connect & Tool Execution', () => {
let state: MockState;
test.beforeEach(async ({ page }) => {
// Seed the installed server in `error` status: the detail offers a Connect
// affordance AND the status poll stays active (error is non-terminal), so
// the status flips to connected once the modal connects.
state = {
installed: [makeInstalledServer()],
statuses: [
{
server_id: 'srv_installed_1',
qualified_name: 'io.github.test/memory-server',
display_name: 'Memory Server',
status: 'error',
tool_count: 0,
},
],
};
await seedLocalStorage(page);
await setupMockRpc(page, state);
await navigateToMcpTab(page);
});
test('connect a server, then run one of its tools and see the result', async ({ page }) => {
// Open the installed server's detail view.
const row = page.locator('table tbody tr', {
has: page.locator('td:first-child:has-text("Memory Server")'),
});
await row.click();
await expect(page.locator('button:has-text("Go back")')).toBeVisible({ timeout: 5_000 });
// Connect via the upfront auth modal (no-auth server → a single Connect
// button). `exact` avoids the "Connections" sidebar nav button.
await page.getByRole('button', { name: 'Connect', exact: true }).click();
const connectDialog = page.getByRole('dialog');
await expect(connectDialog).toBeVisible({ timeout: 5_000 });
await connectDialog.getByRole('button', { name: /^Connect$/ }).click();
// The status poll flips the server to connected → its (collapsed) tool list
// appears. Expand it, then open the execution playground for a tool. This is
// the connect → tool step of the install→connect→tool path.
const toolsToggle = page.getByRole('button', { name: /tools available/ });
await expect(toolsToggle).toBeVisible({ timeout: 15_000 });
await toolsToggle.click();
const tryButton = page.getByRole('button', {
name: 'Open execution playground for create_memory',
});
await expect(tryButton).toBeVisible({ timeout: 5_000 });
await tryButton.click();
// The Tool Execution Playground opens; run the tool and assert the result
// surfaced from the mocked `mcp_clients_tool_call`.
const playground = page.getByRole('dialog');
await expect(playground.getByText('Run create_memory')).toBeVisible({ timeout: 5_000 });
await playground.getByRole('button', { name: 'Run tool' }).click();
await expect(page.getByTestId('mcp-playground-result')).toContainText('memory created id=42', {
timeout: 10_000,
});
});
});
test.describe('MCP Tab — Empty & Edge States', () => {
test('empty installed list shows appropriate message', async ({ page }) => {
const state: MockState = { installed: [], statuses: [] };
+36 -36
View File
@@ -37,13 +37,13 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil
### 0.2 Installation & Launch
| ID | Feature | Layer | Test path(s) | Status | Notes |
| ----- | ------------------------------- | ----- | -------------------- | ------ | ---------------------------------------- |
| 0.2.1 | DMG Installation Flow | MS | release-manual-smoke | 🚫 | OS-level Finder drag |
| 0.2.2 | Gatekeeper Validation | MS | release-manual-smoke | 🚫 | OS-level signature check |
| 0.2.3 | Code Signing Verification | MS | release-manual-smoke | 🚫 | `codesign --verify` capture in checklist |
| 0.2.4 | First Launch Permissions Prompt | MS | release-manual-smoke | 🚫 | TCC prompts non-driver-automatable |
| 0.2.5 | First-Run Harness Init (Python/spaCy/Node) | RU+RI+VU | `src/openhuman/harness_init/*` (`#[cfg(test)]`), `src/openhuman/runtime_python/downloader_tests.rs`, `tests/json_rpc_e2e.rs`, `app/src/services/harnessInitService.test.ts`, `app/src/components/InitProgressScreen/InitProgressScreen.test.tsx` | ✅ | Eager startup provisioning + `harness_init_status`/`_run` RPC + blocking init overlay; managed CPython pinned to 3.13.x. Live download/model fetch is `MS` (network) |
| ID | Feature | Layer | Test path(s) | Status | Notes |
| ----- | ------------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 0.2.1 | DMG Installation Flow | MS | release-manual-smoke | 🚫 | OS-level Finder drag |
| 0.2.2 | Gatekeeper Validation | MS | release-manual-smoke | 🚫 | OS-level signature check |
| 0.2.3 | Code Signing Verification | MS | release-manual-smoke | 🚫 | `codesign --verify` capture in checklist |
| 0.2.4 | First Launch Permissions Prompt | MS | release-manual-smoke | 🚫 | TCC prompts non-driver-automatable |
| 0.2.5 | First-Run Harness Init (Python/spaCy/Node) | RU+RI+VU | `src/openhuman/harness_init/*` (`#[cfg(test)]`), `src/openhuman/runtime_python/downloader_tests.rs`, `tests/json_rpc_e2e.rs`, `app/src/services/harnessInitService.test.ts`, `app/src/components/InitProgressScreen/InitProgressScreen.test.tsx` | ✅ | Eager startup provisioning + `harness_init_status`/`_run` RPC + blocking init overlay; managed CPython pinned to 3.13.x. Live download/model fetch is `MS` (network) |
### 0.3 Updates & Reinstallation
@@ -177,14 +177,14 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil
### 4.2 Messaging
| ID | Feature | Layer | Test path(s) | Status | Notes |
| ----- | -------------------------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 4.2.1 | User Message Handling | WD+RI | `conversations-web-channel-flow.spec.ts`, `tests/json_rpc_e2e.rs` | ✅ | |
| 4.2.2 | AI Response Generation | WD | `agent-review.spec.ts` | ✅ | Mock LLM |
| 4.2.3 | Streaming Responses | RI | `tests/json_rpc_e2e.rs`, `tests/agent_harness_e2e.rs` | ✅ | `tests/agent_harness_e2e.rs` adds provider-level SSE tool-arg accumulation (chunked args reassembled + parsed) and engine-level delta forwarding (#3471) |
| 4.2.4 | Parallel inference (cross-thread + within-thread forked turns) | RU+VU | `src/openhuman/channels/providers/web_tests.rs`, `app/src/store/__tests__/chatRuntimeSlice.test.ts`, `app/src/providers/__tests__/ChatRuntimeProvider.test.tsx` | 🟡 | Concurrent same-/cross-thread dispatch, cooperative `CancellationToken` teardown, and parallel-lane stream routing covered; dedicated WD E2E is a follow-up |
| 4.2.5 | Per-thread todo list (plan strip above composer) | RU+VU+WD | `src/openhuman/agent/tools/todo.rs`, `app/src/pages/conversations/components/ThreadTodoStrip.test.tsx`, `app/test/e2e/specs/chat-thread-todo-strip.spec.ts` | ✅ | Read-only thread-scoped todo strip fed by `task_board_updated`; agent `todo` tool guidance + thread binding; E2E drives a `todo` tool call and asserts the card renders |
| 4.2.6 | Background-activity panel (chat-header Background tasks button) | VU+WD | `app/src/pages/conversations/hooks/useBackgroundActivity.test.ts`, `app/src/pages/conversations/components/__tests__/BackgroundActivityRows.test.tsx`, `app/test/e2e/specs/chat-background-activity-panel.spec.ts` | ✅ | View-only panel surfacing this chat's async sub-agents + global cron jobs, subconscious/heartbeat status, and memory syncing; freshness-only "Syncing now" labeling; E2E opens the panel and asserts its sections render and close |
| ID | Feature | Layer | Test path(s) | Status | Notes |
| ----- | ------------------------------------------------------------------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 4.2.1 | User Message Handling | WD+RI | `conversations-web-channel-flow.spec.ts`, `tests/json_rpc_e2e.rs` | ✅ | |
| 4.2.2 | AI Response Generation | WD | `agent-review.spec.ts` | ✅ | Mock LLM |
| 4.2.3 | Streaming Responses | RI | `tests/json_rpc_e2e.rs`, `tests/agent_harness_e2e.rs` | ✅ | `tests/agent_harness_e2e.rs` adds provider-level SSE tool-arg accumulation (chunked args reassembled + parsed) and engine-level delta forwarding (#3471) |
| 4.2.4 | Parallel inference (cross-thread + within-thread forked turns) | RU+VU | `src/openhuman/channels/providers/web_tests.rs`, `app/src/store/__tests__/chatRuntimeSlice.test.ts`, `app/src/providers/__tests__/ChatRuntimeProvider.test.tsx` | 🟡 | Concurrent same-/cross-thread dispatch, cooperative `CancellationToken` teardown, and parallel-lane stream routing covered; dedicated WD E2E is a follow-up |
| 4.2.5 | Per-thread todo list (plan strip above composer) | RU+VU+WD | `src/openhuman/agent/tools/todo.rs`, `app/src/pages/conversations/components/ThreadTodoStrip.test.tsx`, `app/test/e2e/specs/chat-thread-todo-strip.spec.ts` | ✅ | Read-only thread-scoped todo strip fed by `task_board_updated`; agent `todo` tool guidance + thread binding; E2E drives a `todo` tool call and asserts the card renders |
| 4.2.6 | Background-activity panel (chat-header Background tasks button) | VU+WD | `app/src/pages/conversations/hooks/useBackgroundActivity.test.ts`, `app/src/pages/conversations/components/__tests__/BackgroundActivityRows.test.tsx`, `app/test/e2e/specs/chat-background-activity-panel.spec.ts` | ✅ | View-only panel surfacing this chat's async sub-agents + global cron jobs, subconscious/heartbeat status, and memory syncing; freshness-only "Syncing now" labeling; E2E opens the panel and asserts its sections render and close |
| 4.2.7 | Plan-mode review (Approve / Reject / Send-feedback before execute) | RU+RI+VU | `src/openhuman/plan_review/gate.rs`, `src/openhuman/plan_review/tool.rs`, `src/openhuman/plan_review/schemas.rs`, `tests/json_rpc_e2e.rs`, `app/src/pages/conversations/components/PlanReviewCard.test.tsx`, `app/src/pages/__tests__/Conversations.render.test.tsx` | ✅ | Interactive turns call `request_plan_review`, which parks the LIVE turn on the in-memory `PlanReviewGate` (oneshot) until the user decides; `plan_review_request` socket event drives `PlanReviewCard`, which resolves via `openhuman.plan_review_decide` (approve resumes-and-executes / reject resumes-and-stops / revise resumes-with-feedback). RU covers gate park/resolve/timeout + tool auto-approve + parking; RI covers the decide RPC; VU covers the card + provider wiring. WD E2E (agent-driven park flow) tracked as follow-up |
| 4.2.8 | Composer attachments (image / video / document; drag-drop + paste) | VU | `app/src/lib/attachments.test.ts`, `app/src/components/chat/__tests__/ChatComposer.test.tsx`, `app/src/pages/__tests__/Conversations.attachments.test.tsx` | 🟡 | Attach affordance gated on the resolved vision tier (images/video need vision; documents flow on any model); video is sampled into still frames client-side and forwarded through the existing `[IMAGE:]` vision path; drag-drop + clipboard-paste reuse the picker ingest. VU covers MIME/kind/limits/marker building + drag-drop + paste; real video decode and the frames→vision round-trip are manual-smoke only (jsdom has no video codec). WD E2E is a follow-up |
@@ -458,22 +458,22 @@ End-to-end coverage of the agent harness via the web-chat RPC surface against an
### 11.1 Analysis Engine
| ID | Feature | Layer | Test path(s) | Status | Notes |
| ------- | ------------------------------------------------------------------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 11.1.1 | Multi-Source Analysis | RI | `tests/memory_graph_sync_e2e.rs` | 🟡 | Frontend trigger untested |
| 11.1.2 | Actionable Item Extraction | VU | `app/src/components/intelligence/__tests__/utils.test.ts` | ✅ | Was ❌ |
| 11.1.3 | Analyze Trigger | WD | `app/test/e2e/specs/insights-dashboard.spec.ts` mounts the route; explicit analyze-handler invocation TBD | 🟡 | Route mounts and search/filter UI assert — full analyze trigger flow tracked as follow-up |
| 11.1.4 | MCP server (stdio + HTTP) | RU | `src/openhuman/mcp_server/` | ✅ | Stdio framing plus Streamable HTTP/SSE session lifecycle; `McpHttpClient` round-trip tests |
| 11.1.5 | Global tool registry | RI | `src/openhuman/tool_registry/`, `tests/json_rpc_e2e.rs`, `tests/domain_modules_e2e.rs`, `tests/worker_b_domain_e2e.rs` | ✅ | Read-only MCP/controller discovery with routes, schemas, version, allowed agents, and health |
| 11.1.6 | SearXNG MCP search | RU | `src/openhuman/integrations/searxng.rs`, `src/openhuman/mcp_server/tools.rs`, `src/openhuman/tools/schemas.rs` | ✅ | Self-hosted search config, normalized results, MCP argument validation, and mocked HTTP execution |
| 11.1.7 | Bundled prompt resources | RU | `src/openhuman/mcp_server/resources.rs`, `src/openhuman/mcp_server/protocol.rs` | ✅ | `resources/list` catalog + `resources/read` happy path, -32002 unknown URI, -32602 missing param, catalog-mirrors-BUILTINS parity test |
| 11.1.8 | Resource templates list | RU | `src/openhuman/mcp_server/resources.rs`, `src/openhuman/mcp_server/protocol.rs` | ✅ | `resources/templates/list` returns `{resourceTemplates: []}` (static catalog), tolerates unknown/cursor params |
| 11.1.10 | MCP registry install→connect→tool_call | RI | `tests/json_rpc_e2e.rs` (`mcp_clients_install_connect_tool_call_happy_path`), `tests/mcp_registry_e2e.rs`, `src/openhuman/mcp_registry/setup_ops.rs` (#3039) | ✅ | HTTP-RPC happy path install→connect→tool_call→update_env against `test-mcp-stub`; transport-aware install (stdio + http_remote) via `build_install_transport` |
| 11.1.11 | MCP env reconfigure + registry creds | RI/VU | `tests/json_rpc_e2e.rs` (`mcp_clients_registry_settings_roundtrip`), `src/openhuman/mcp_registry/registries/mcp_official.rs`, `app/src/components/channels/mcp/InstalledServerDetail.test.tsx` (#3039) | ✅ | `update_env` persist+reconnect; `registry_settings` get/set with secrets write-only (config-first, env-fallback); reconfigure form validation |
| 11.1.12 | MCP UI surface + setup-agent client | VU | `app/src/components/channels/mcp/InstallDialog.test.tsx`, `app/src/services/api/mcpClientsApi.test.ts`, `app/src/services/api/mcpSetupApi.test.ts` (#3039) | ✅ | Skills `?tab=mcp` renders `McpServersTab` (not Coming Soon); auto-connect on install (best-effort); typed `mcpSetupApi` wrapper |
| 11.1.13 | MCP HTTP-remote auth (token / Bearer / OAuth) + redirect resolution | RU/VU | `src/openhuman/mcp_registry/connections.rs` (`build_http_auth*`, `resolve_final_url`), `src/openhuman/mcp_registry/oauth.rs` (PKCE/token/bundle/callback port), `app/src/components/channels/mcp/ConnectAuthModal.test.tsx` (#3495) | ✅ | Bearer/raw scheme + custom headers; redirect-final-URL resolved before auth; OAuth dynamic client registration + PKCE + refresh; tokens MERGED into stored env; credentials stored encrypted locally, never sent to backend |
| 11.1.14 | MCP "Help & configure" assistant | VU/RU | `app/src/components/channels/mcp/ConfigAssistantPanel.test.tsx`, `app/src/components/channels/mcp/ConfigHelpModal.test.tsx`, `src/openhuman/mcp_registry/ops.rs` (`invoke_config_assist_agent`) (#3495) | ✅ | Fixed server-specific prompt runs an agentic turn scoped to web_search_tool/web_fetch/curl only; markdown-rendered reply; per-MCP chat persisted while on the detail page |
| 11.1.15 | Agent uses connected MCP servers in chat | RU | `src/openhuman/agent_registry/agents/loader.rs` (`orchestrator_subagents_include_mcp_agent`, `mcp_agent_drives_connected_servers_without_install_or_shell`, `planner_has_readonly_mcp_discovery_not_execute`), `src/openhuman/agent_registry/agents/orchestrator/prompt.rs` (`connected_mcp_block_*`), `src/openhuman/agent/harness/session/turn_tests.rs` (`mcp_announcement_fires_once_for_new_server`), `src/openhuman/mcp_registry/{tools,connections}.rs` (#3495) | ✅ | `use_mcp_server` delegate → `mcp_agent` worker (discover→list→call); `mcp_registry_list_tools` read-only discovery; orchestrator `## Connected MCP Servers` prompt block + mid-session connect announcement on the user turn; planner read-only MCP discovery (no `tool_call`) |
| ID | Feature | Layer | Test path(s) | Status | Notes |
| ------- | ------------------------------------------------------------------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 11.1.1 | Multi-Source Analysis | RI | `tests/memory_graph_sync_e2e.rs` | 🟡 | Frontend trigger untested |
| 11.1.2 | Actionable Item Extraction | VU | `app/src/components/intelligence/__tests__/utils.test.ts` | ✅ | Was ❌ |
| 11.1.3 | Analyze Trigger | WD | `app/test/e2e/specs/insights-dashboard.spec.ts` mounts the route; explicit analyze-handler invocation TBD | 🟡 | Route mounts and search/filter UI assert — full analyze trigger flow tracked as follow-up |
| 11.1.4 | MCP server (stdio + HTTP) | RU | `src/openhuman/mcp_server/` | ✅ | Stdio framing plus Streamable HTTP/SSE session lifecycle; `McpHttpClient` round-trip tests |
| 11.1.5 | Global tool registry | RI | `src/openhuman/tool_registry/`, `tests/json_rpc_e2e.rs`, `tests/domain_modules_e2e.rs`, `tests/worker_b_domain_e2e.rs` | ✅ | Read-only MCP/controller discovery with routes, schemas, version, allowed agents, and health |
| 11.1.6 | SearXNG MCP search | RU | `src/openhuman/integrations/searxng.rs`, `src/openhuman/mcp_server/tools.rs`, `src/openhuman/tools/schemas.rs` | ✅ | Self-hosted search config, normalized results, MCP argument validation, and mocked HTTP execution |
| 11.1.7 | Bundled prompt resources | RU | `src/openhuman/mcp_server/resources.rs`, `src/openhuman/mcp_server/protocol.rs` | ✅ | `resources/list` catalog + `resources/read` happy path, -32002 unknown URI, -32602 missing param, catalog-mirrors-BUILTINS parity test |
| 11.1.8 | Resource templates list | RU | `src/openhuman/mcp_server/resources.rs`, `src/openhuman/mcp_server/protocol.rs` | ✅ | `resources/templates/list` returns `{resourceTemplates: []}` (static catalog), tolerates unknown/cursor params |
| 11.1.10 | MCP registry install→connect→tool_call | RI | `tests/json_rpc_e2e.rs` (`mcp_clients_install_connect_tool_call_happy_path`), `tests/mcp_registry_e2e.rs`, `src/openhuman/mcp_registry/setup_ops.rs`, `app/test/playwright/specs/mcp-tab-flow.spec.ts` (#3039, #4272) | ✅ | HTTP-RPC happy path install→connect→tool_call→update_env against `test-mcp-stub`; transport-aware install (stdio + http_remote) via `build_install_transport`; UI-level Playwright flow drives browse→install→connect→run-tool (#4272) |
| 11.1.11 | MCP env reconfigure + registry creds | RI/VU | `tests/json_rpc_e2e.rs` (`mcp_clients_registry_settings_roundtrip`), `src/openhuman/mcp_registry/registries/mcp_official.rs`, `app/src/components/channels/mcp/InstalledServerDetail.test.tsx` (#3039) | ✅ | `update_env` persist+reconnect; `registry_settings` get/set with secrets write-only (config-first, env-fallback); reconfigure form validation |
| 11.1.12 | MCP UI surface + setup-agent client | VU/RU | `app/src/components/channels/mcp/InstallDialog.test.tsx`, `app/src/components/channels/mcp/McpServersTab.test.tsx`, `app/src/services/api/mcpClientsApi.test.ts`, `app/src/services/api/mcpSetupApi.test.ts`, `src/openhuman/mcp_registry/{curation,registry,registries/mcp_official}.rs` (#3039, #4272) | ✅ | Skills `?tab=mcp` renders `McpServersTab` (not Coming Soon); auto-connect on install (best-effort); typed `mcpSetupApi` wrapper; curated "perfect server" catalog (declared website + named credential) with official-vendor badge + official-first order; namespace-stripped relevance search + server-side Stdio/Hosted transport filter; clickable Website/Repo links; wired connection health toolbar (Retry all / Disconnect all) (#4272) |
| 11.1.13 | MCP HTTP-remote auth (token / Bearer / OAuth) + redirect resolution | RU/VU | `src/openhuman/mcp_registry/connections.rs` (`build_http_auth*`, `resolve_final_url`), `src/openhuman/mcp_registry/oauth.rs` (PKCE/token/bundle/callback port), `app/src/components/channels/mcp/ConnectAuthModal.test.tsx` (#3495) | ✅ | Bearer/raw scheme + custom headers; redirect-final-URL resolved before auth; OAuth dynamic client registration + PKCE + refresh; tokens MERGED into stored env; credentials stored encrypted locally, never sent to backend |
| 11.1.14 | MCP "Help & configure" assistant | VU/RU | `app/src/components/channels/mcp/ConfigAssistantPanel.test.tsx`, `app/src/components/channels/mcp/ConfigHelpModal.test.tsx`, `src/openhuman/mcp_registry/ops.rs` (`invoke_config_assist_agent`) (#3495) | ✅ | Server-specific prompt offered as a one-click "Get step-by-step setup help" action (on-demand, no longer auto-run on open — #4272), running an agentic turn scoped to web_search_tool/web_fetch/curl only; markdown-rendered reply; per-MCP chat persisted while on the detail page |
| 11.1.15 | Agent uses connected MCP servers in chat | RU | `src/openhuman/agent_registry/agents/loader.rs` (`orchestrator_subagents_include_mcp_agent`, `mcp_agent_drives_connected_servers_without_install_or_shell`, `planner_has_readonly_mcp_discovery_not_execute`), `src/openhuman/agent_registry/agents/orchestrator/prompt.rs` (`connected_mcp_block_*`), `src/openhuman/agent/harness/session/turn_tests.rs` (`mcp_announcement_fires_once_for_new_server`), `src/openhuman/mcp_registry/{tools,connections}.rs` (#3495) | ✅ | `use_mcp_server` delegate → `mcp_agent` worker (discover→list→call); `mcp_registry_list_tools` read-only discovery; orchestrator `## Connected MCP Servers` prompt block + mid-session connect announcement on the user turn; planner read-only MCP discovery (no `tool_call`) |
<!-- 11.1.9 Vault Markdown Writes — removed: Knowledge Vaults dropped (vault domain + VaultPanel deleted). -->
@@ -559,11 +559,11 @@ End-to-end coverage of the agent harness via the web-chat RPC surface against an
### 13.6 Keyboard Shortcuts & Command Surface
| ID | Feature | Layer | Test path(s) | Status | Notes |
| ------ | ---------------------------------------- | ----- | ----------------------------------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 13.6.1 | Command Palette (⌘K / ⌘P) | VU+WD | `app/src/lib/commands/__tests__/globalActions.test.tsx`, `app/src/components/commands/__tests__/CommandProvider.test.tsx`, `app/test/e2e/specs/command-palette.spec.ts` | ✅ | Opens via ⌘K, runs an action, lists seed nav actions, Esc closes |
| 13.6.2 | Keyboard Shortcuts help directory (? / ⌘/) | VU+WD | `app/src/components/shortcuts/__tests__/shortcutsView.test.tsx`, `app/src/components/commands/__tests__/CommandProvider.test.tsx`, `app/test/e2e/specs/command-palette.spec.ts` | ✅ | Registry-driven grouped list; opens via `?` and ⌘/ (mutually exclusive with palette); also reachable from the sidebar shortcut icon + Settings → Keyboard Shortcuts |
| 13.6.3 | Global shortcut map (nav / chat / view / profiles) | VU | `app/src/lib/commands/__tests__/globalActions.test.tsx` | ✅ | Control-based nav (`ctrl`/`mod` per-OS), New Chat / Toggle Sidebar handlers, alias keys, and wired-but-hidden profile switches |
| ID | Feature | Layer | Test path(s) | Status | Notes |
| ------ | -------------------------------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 13.6.1 | Command Palette (⌘K / ⌘P) | VU+WD | `app/src/lib/commands/__tests__/globalActions.test.tsx`, `app/src/components/commands/__tests__/CommandProvider.test.tsx`, `app/test/e2e/specs/command-palette.spec.ts` | ✅ | Opens via ⌘K, runs an action, lists seed nav actions, Esc closes |
| 13.6.2 | Keyboard Shortcuts help directory (? / ⌘/) | VU+WD | `app/src/components/shortcuts/__tests__/shortcutsView.test.tsx`, `app/src/components/commands/__tests__/CommandProvider.test.tsx`, `app/test/e2e/specs/command-palette.spec.ts` | ✅ | Registry-driven grouped list; opens via `?` and ⌘/ (mutually exclusive with palette); also reachable from the sidebar shortcut icon + Settings → Keyboard Shortcuts |
| 13.6.3 | Global shortcut map (nav / chat / view / profiles) | VU | `app/src/lib/commands/__tests__/globalActions.test.tsx` | ✅ | Control-based nav (`ctrl`/`mod` per-OS), New Chat / Toggle Sidebar handlers, alias keys, and wired-but-hidden profile switches |
---
+84
View File
@@ -45,6 +45,35 @@ pub fn tag_official(servers: &mut [SmitheryServerSummary]) {
}
}
/// Whether a catalog row fully specifies how to connect *from its metadata
/// alone* — no probe, no guessing. A "perfect" server declares both a
/// `website_url` (a trust/quality signal and the user's get-key destination)
/// and a named static credential (`auth_kind == "api_key"`).
pub(super) fn is_perfect_server(s: &SmitheryServerSummary) -> bool {
s.website_url
.as_deref()
.is_some_and(|u| !u.trim().is_empty())
&& s.auth_kind.as_deref() == Some("api_key")
}
/// Strict "perfect server" catalog filter. Keeps only [`is_perfect_server`]
/// rows, dropping OAuth-only, open, and under-declared servers (and every
/// Smithery summary, which carries neither website nor declared auth). This is
/// a deliberate quality-over-quantity trade-off (#4272): the user only ever
/// sees servers that can be installed and connected with confidence. Returns
/// the number of rows dropped so callers can log the trim. Mutates in place.
pub fn retain_perfect_servers(servers: &mut Vec<SmitheryServerSummary>) -> usize {
let before = servers.len();
servers.retain(is_perfect_server);
before - servers.len()
}
/// Float the canonical first-party (`official`) servers to the top while
/// preserving the registry's relevance order for everything else (stable sort).
pub fn float_official_first(servers: &mut [SmitheryServerSummary]) {
servers.sort_by_key(|s| !s.official);
}
#[cfg(test)]
mod tests {
use super::*;
@@ -59,10 +88,21 @@ mod tests {
is_deployed: true,
source: "mcp_official".to_string(),
official: false,
website_url: None,
auth_kind: None,
extra: Default::default(),
}
}
/// A "perfect" server: declared website + api_key auth.
fn perfect(qualified_name: &str) -> SmitheryServerSummary {
SmitheryServerSummary {
website_url: Some("https://vendor.example".to_string()),
auth_kind: Some("api_key".to_string()),
..server(qualified_name)
}
}
#[test]
fn tags_only_exact_canonical_servers() {
let mut servers = vec![
@@ -87,4 +127,48 @@ mod tests {
"a name merely containing 'stripe' must not be marked official"
);
}
#[test]
fn retain_perfect_keeps_only_website_plus_api_key() {
let mut servers = vec![
perfect("com.acme/mcp"), // website + api_key → kept
SmitheryServerSummary {
auth_kind: None,
..perfect("oauth/srv")
}, // no key → dropped
SmitheryServerSummary {
website_url: None,
..perfect("nosite/srv")
}, // no site → dropped
SmitheryServerSummary {
website_url: Some(" ".to_string()),
..perfect("blank/srv")
}, // blank site → dropped
server("smi/community"), // neither → dropped
];
let dropped = retain_perfect_servers(&mut servers);
let slugs: Vec<_> = servers.iter().map(|s| s.qualified_name.as_str()).collect();
assert_eq!(slugs, vec!["com.acme/mcp"]);
assert_eq!(dropped, 4);
}
#[test]
fn float_official_first_is_stable() {
let mut servers = vec![
perfect("a/one"),
SmitheryServerSummary {
official: true,
..perfect("b/official")
},
perfect("c/two"),
];
float_official_first(&mut servers);
let slugs: Vec<_> = servers.iter().map(|s| s.qualified_name.as_str()).collect();
// Official floats to the top; the rest keep their relative order.
assert_eq!(slugs, vec!["b/official", "a/one", "c/two"]);
}
}
+12 -5
View File
@@ -23,6 +23,7 @@ use super::types::{CommandKind, ConnStatus, InstalledServer};
pub async fn mcp_clients_registry_search(
config: &Config,
query: Option<String>,
transport: Option<String>,
page: Option<u32>,
page_size: Option<u32>,
) -> Result<RpcOutcome<Value>, String> {
@@ -30,16 +31,22 @@ pub async fn mcp_clients_registry_search(
let page_size = page_size.unwrap_or(20);
tracing::debug!(
"[mcp-client] registry_search query={:?} page={} page_size={}",
"[mcp-client] registry_search query={:?} transport={:?} page={} page_size={}",
query,
transport,
page,
page_size
);
let (servers, total_pages) =
registry::registry_search(config, query.as_deref(), page, page_size)
.await
.map_err(|e| e.to_string())?;
let (servers, total_pages) = registry::registry_search(
config,
query.as_deref(),
transport.as_deref(),
page,
page_size,
)
.await
.map_err(|e| e.to_string())?;
Ok(RpcOutcome::new(
json!({ "servers": servers, "page": page, "total_pages": total_pages }),
@@ -563,9 +563,40 @@ struct OfficialServer {
/// Installable subprocess packages (npm, pip, brew, …).
#[serde(default)]
packages: Vec<OfficialPackage>,
/// Vendor/site URL, when declared. Trust/quality signal required by the
/// strict "perfect server" catalog filter and rendered as a clickable link.
#[serde(default, rename = "websiteUrl")]
website_url: Option<String>,
}
impl OfficialServer {
/// Non-empty declared `websiteUrl`, if any.
fn website(&self) -> Option<String> {
self.website_url
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
}
/// Whether the server *declares* a named static secret credential in its
/// schema — a secret/`Authorization` header or a secret env var. This is the
/// metadata signal for "static API key / token", with no probe and no
/// guessing; it drives `auth_kind == "api_key"`.
fn declares_secret_credential(&self) -> bool {
let header = self.remotes.iter().any(|r| {
r.headers
.iter()
.any(|h| h.is_secret == Some(true) || h.name.eq_ignore_ascii_case("authorization"))
});
let env = self.packages.iter().any(|p| {
p.environment_variables
.iter()
.any(|e| e.is_secret == Some(true))
});
header || env
}
fn display_name(&self) -> String {
if let Some(title) = self.title.as_deref().filter(|s| !s.trim().is_empty()) {
return title.to_string();
@@ -584,6 +615,12 @@ impl OfficialServer {
fn into_summary(self) -> SmitheryServerSummary {
let display = self.display_name();
let website_url = self.website();
let auth_kind = if self.declares_secret_credential() {
Some("api_key".to_string())
} else {
None
};
SmitheryServerSummary {
qualified_name: self.name.clone(),
display_name: display,
@@ -593,6 +630,8 @@ impl OfficialServer {
is_deployed: !self.remotes.is_empty(),
source: SOURCE_MCP_OFFICIAL.to_string(),
official: false, // tagged later by the registry dispatcher
website_url,
auth_kind,
extra: std::collections::HashMap::new(),
}
}
@@ -819,6 +858,63 @@ mod tests {
assert_eq!(sum.source, SOURCE_MCP_OFFICIAL);
}
#[test]
fn into_summary_stamps_website_and_api_key_from_declared_secret_header() {
// A server declaring a secret Authorization header + a websiteUrl is a
// "perfect" server: auth_kind=api_key (from metadata, no probe) + site.
let s: OfficialServer = serde_json::from_value(json!({
"name": "com.acme/mcp",
"websiteUrl": "https://www.acme.ai",
"remotes": [{
"url": "https://api.acme.ai/mcp",
"headers": [{ "name": "Authorization", "isSecret": true }],
}],
}))
.unwrap();
let sum = s.into_summary();
assert_eq!(sum.website_url.as_deref(), Some("https://www.acme.ai"));
assert_eq!(sum.auth_kind.as_deref(), Some("api_key"));
}
#[test]
fn into_summary_secret_env_var_also_counts_as_api_key() {
let s: OfficialServer = serde_json::from_value(json!({
"name": "io.github.x/y",
"websiteUrl": "https://site.example",
"packages": [{
"registryType": "npm",
"environmentVariables": [{ "name": "API_KEY", "isSecret": true }],
}],
}))
.unwrap();
assert_eq!(s.into_summary().auth_kind.as_deref(), Some("api_key"));
}
#[test]
fn into_summary_no_auth_kind_when_no_secret_declared() {
// OAuth/open servers declare no key in metadata → auth_kind=None. The
// strict catalog filter drops these even though they carry a website.
let s: OfficialServer = serde_json::from_value(json!({
"name": "open/server",
"websiteUrl": "https://x.example",
"remotes": [{ "url": "https://open.example.com/mcp" }],
}))
.unwrap();
let sum = s.into_summary();
assert_eq!(sum.website_url.as_deref(), Some("https://x.example"));
assert_eq!(sum.auth_kind, None);
}
#[test]
fn into_summary_trims_blank_website_to_none() {
let s: OfficialServer = serde_json::from_value(json!({
"name": "blank/site",
"websiteUrl": " ",
}))
.unwrap();
assert_eq!(s.into_summary().website_url, None);
}
#[test]
fn list_response_tolerates_missing_metadata() {
let raw = json!({ "servers": [] });
@@ -183,6 +183,15 @@ fn tag_source(mut servers: Vec<SmitheryServerSummary>) -> Vec<SmitheryServerSumm
if s.source.is_empty() {
s.source = SOURCE_SMITHERY.to_string();
}
// `website_url` / `auth_kind` are curation-only trust signals derived by
// the official adapter — never accept them from the Smithery wire (a
// payload emitting either key must not survive the strict "perfect
// server" filter on spoofed metadata). Scrub the fields AND the flatten
// bucket so they can't re-serialize.
s.website_url = None;
s.auth_kind = None;
s.extra.remove("website_url");
s.extra.remove("auth_kind");
}
servers
}
@@ -225,4 +234,35 @@ mod tests {
let encoded = urlencoding_encode("hello world");
assert_eq!(encoded, "hello%20world");
}
#[test]
fn tag_source_scrubs_curation_only_trust_signals_from_the_wire() {
// A (hostile/buggy) Smithery payload emitting website_url/auth_kind —
// both on the field and via the flatten bucket — must not survive: these
// are adapter-derived signals the strict filter trusts.
let mut server = SmitheryServerSummary {
qualified_name: "smi/evil".to_string(),
display_name: "Evil".to_string(),
description: None,
icon_url: None,
use_count: 0,
is_deployed: false,
source: String::new(),
official: false,
website_url: Some("https://spoofed.example".to_string()),
auth_kind: Some("api_key".to_string()),
extra: Default::default(),
};
server
.extra
.insert("auth_kind".to_string(), serde_json::json!("api_key"));
let tagged = tag_source(vec![server]);
assert_eq!(tagged[0].source, SOURCE_SMITHERY);
assert_eq!(tagged[0].website_url, None);
assert_eq!(tagged[0].auth_kind, None);
assert!(!tagged[0].extra.contains_key("website_url"));
assert!(!tagged[0].extra.contains_key("auth_kind"));
}
}
+189 -46
View File
@@ -1,10 +1,11 @@
//! Multi-registry dispatch entry point.
//! Multi-registry search + detail dispatch.
//!
//! `registry_search` fans out to every registry in
//! [`super::registries::enabled_registries`], runs them in parallel, and
//! returns merged results (failed registries are logged and skipped so one
//! flaky upstream doesn't blank the UI). The official modelcontextprotocol.io
//! registry is listed first so its results take priority.
//! `registry_search` fans out to every enabled registry in parallel and
//! over-fetches to offset the strict "perfect server" filter, then merges +
//! dedups, badges the canonical vendor server, drops non-perfect rows, applies
//! the transport filter, and floats official servers to the top. (A previous
//! attempt to fetch + cache the *entire* catalog up front was reverted: the full
//! cursor walk exceeded the 30 s RPC budget and blanked the page.)
//!
//! `registry_get` routes by [`super::types::SmitheryServerDetail::source`].
//! The caller can pass an explicit source prefix using
@@ -23,21 +24,49 @@ use super::types::{SmitheryServerDetail, SmitheryServerSummary};
const SOURCE_SEPARATOR: &str = "::";
/// Search every enabled registry in parallel; merge results. `total_pages`
/// is the max page count reported across registries (best-effort upper
/// bound).
/// Over-fetch factor for the strict catalog. The "perfect server" filter drops
/// most upstream rows, so fetching exactly `page_size` would return a
/// near-empty page. We pull a multiple of the requested size so each returned
/// page lands close to `page_size` usable rows.
const STRICT_OVERFETCH_FACTOR: u32 = 5;
/// Cap on the per-page upstream fetch (registry list endpoints cap `limit`
/// around here anyway).
const MAX_FETCH_PAGE_SIZE: u32 = 100;
/// Raw rows to request upstream for a requested `page_size` of strict results.
fn strict_fetch_size(page_size: u32) -> u32 {
page_size
.saturating_mul(STRICT_OVERFETCH_FACTOR)
.clamp(1, MAX_FETCH_PAGE_SIZE)
}
/// Per-registry search results tagged with their source id, for [`merge_registry_results`].
type LabelledResults = Vec<(&'static str, Result<(Vec<SmitheryServerSummary>, u32)>)>;
/// Search every enabled registry in parallel, over-fetch, merge, then curate.
/// `query` is passed to the registries' own search; `transport` (`"stdio"` |
/// `"hosted"` | `"all"`/`None`) filters the merged rows by how they run.
pub async fn registry_search(
config: &Config,
query: Option<&str>,
transport: Option<&str>,
page: u32,
page_size: u32,
) -> Result<(Vec<SmitheryServerSummary>, u32)> {
let registries = enabled_registries(config);
let queries = registries
.iter()
.map(|r| r.search(config, query, page, page_size));
let results = join_all(queries).await;
let fetch_size = strict_fetch_size(page_size);
let results = join_all(
registries
.iter()
.map(|r| r.search(config, query, page, fetch_size)),
)
.await;
// A total outage (every registry errored) is distinct from "no perfect
// servers": return an error so the UI shows its registry error state instead
// of an empty catalog. `merge_registry_results` logs+skips the individual
// failures.
let any_ok = results.iter().any(Result::is_ok);
let labelled = results
.into_iter()
.enumerate()
@@ -46,45 +75,112 @@ pub async fn registry_search(
let (mut merged, mut total_pages) = merge_registry_results(labelled);
// Keep the full deduped catalog browsable — no one-per-service collapse
// (it hides genuinely different community servers and barely trims noise).
// Just badge the canonical first-party server for each known service so the
// official one is easy to spot without throwing any alternatives away.
if !any_ok && !registries.is_empty() {
anyhow::bail!("all MCP registries failed to respond");
}
// Badge the canonical first-party server, drop non-perfect rows, refine
// search relevance, filter by transport, float official to the top.
super::curation::tag_official(&mut merged);
super::curation::retain_perfect_servers(&mut merged);
if let Some(q) = query.map(str::trim).filter(|q| !q.is_empty()) {
merged = refine_by_relevance(merged, q);
}
apply_transport(&mut merged, transport);
super::curation::float_official_first(&mut merged);
if total_pages == 0 {
total_pages = page.max(1);
}
tracing::debug!(
"[mcp-registry] search page={page} returned={} total_pages={total_pages} has_query={} transport={:?}",
merged.len(),
query.map(|q| !q.trim().is_empty()).unwrap_or(false),
transport
);
Ok((merged, total_pages))
}
/// Drop rows that don't match the requested transport (`"stdio"` | `"hosted"`).
/// `None`/`"all"` keeps everything.
fn apply_transport(servers: &mut Vec<SmitheryServerSummary>, transport: Option<&str>) {
if let Some(tp) = transport
.map(str::trim)
.filter(|t| !t.is_empty() && *t != "all")
{
servers.retain(|s| match tp {
"hosted" => s.is_deployed,
"stdio" => !s.is_deployed,
_ => true,
});
}
}
/// Strip a code-host namespace root so a query matches the meaningful part of a
/// slug, not the shared host. `io.github.06ketan/medium-ops` → `06ketan/medium-ops`.
fn searchable_slug(qualified_name: &str) -> &str {
const CODE_HOST_PREFIXES: &[&str] = &["io.github.", "io.gitlab."];
for prefix in CODE_HOST_PREFIXES {
if let Some(rest) = qualified_name.strip_prefix(prefix) {
return rest;
}
}
qualified_name
}
/// Keep only rows matching every query token across the display name, the
/// namespace-stripped slug, and the description — so searching "github" doesn't
/// match every `io.github.<user>/*` community server just by its namespace.
/// Safe-by-default: if the refinement would empty the page, the unrefined rows
/// are returned (a looser list beats a blank one).
fn refine_by_relevance(
servers: Vec<SmitheryServerSummary>,
query: &str,
) -> Vec<SmitheryServerSummary> {
let tokens: Vec<String> = query
.to_lowercase()
.split_whitespace()
.map(String::from)
.collect();
if tokens.is_empty() {
return servers;
}
let refined: Vec<SmitheryServerSummary> = servers
.iter()
.filter(|s| {
let haystack = format!(
"{} {} {}",
s.display_name.to_lowercase(),
searchable_slug(&s.qualified_name).to_lowercase(),
s.description.as_deref().unwrap_or("").to_lowercase()
);
tokens.iter().all(|t| haystack.contains(t.as_str()))
})
.cloned()
.collect();
if refined.is_empty() {
servers
} else {
refined
}
}
/// Merge per-registry search results into one list, dropping exact
/// `qualified_name` duplicates. Registries are passed in priority order
/// (official before Smithery), and the first occurrence of a slug wins — so a
/// package listed on both registries collapses to the higher-priority copy and
/// the UI never shows the same slug twice. `total_pages` is the max reported
/// across the registries that succeeded. Failed registries are logged and
/// skipped so one flaky upstream can't blank the catalog.
fn merge_registry_results(
results: Vec<(&'static str, Result<(Vec<SmitheryServerSummary>, u32)>)>,
) -> (Vec<SmitheryServerSummary>, u32) {
/// (official before Smithery), and the first occurrence of a slug wins.
/// `total_pages` is the max reported across registries that succeeded; failed
/// registries are logged and skipped so one flaky upstream can't blank results.
fn merge_registry_results(results: LabelledResults) -> (Vec<SmitheryServerSummary>, u32) {
let mut merged: Vec<SmitheryServerSummary> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
let mut total_pages: u32 = 0;
let mut dropped: usize = 0;
for (source, res) in results {
match res {
Ok((servers, pages)) => {
tracing::debug!(
"[mcp-registry] {source} search ok servers={} pages={pages}",
servers.len()
);
for server in servers {
if seen.insert(server.qualified_name.clone()) {
merged.push(server);
} else {
dropped += 1;
}
}
total_pages = total_pages.max(pages);
@@ -94,10 +190,6 @@ fn merge_registry_results(
}
}
}
if dropped > 0 {
tracing::debug!("[mcp-registry] dropped {dropped} cross-registry duplicate slug(s)");
}
(merged, total_pages)
}
@@ -145,13 +237,22 @@ mod tests {
is_deployed: false,
source: source.to_string(),
official: false,
website_url: None,
auth_kind: None,
extra: Default::default(),
}
}
#[test]
fn strict_fetch_size_overfetches_and_caps() {
assert_eq!(strict_fetch_size(10), 50);
assert_eq!(strict_fetch_size(30), MAX_FETCH_PAGE_SIZE);
assert_eq!(strict_fetch_size(1_000_000), MAX_FETCH_PAGE_SIZE);
assert_eq!(strict_fetch_size(0), 1);
}
#[test]
fn merge_keeps_higher_priority_duplicate_and_drops_the_rest() {
// `dup/server` is listed on both registries; official is passed first.
let results = vec![
(
"mcp_official",
@@ -179,13 +280,14 @@ mod tests {
let slugs: Vec<_> = merged.iter().map(|s| s.qualified_name.as_str()).collect();
assert_eq!(slugs, vec!["dup/server", "off/only", "smi/only"]);
// The surviving duplicate is the official copy (first occurrence wins).
let dup = merged
.iter()
.find(|s| s.qualified_name == "dup/server")
.unwrap();
assert_eq!(dup.source, "mcp_official");
// total_pages is the max across registries.
assert_eq!(
merged
.iter()
.find(|s| s.qualified_name == "dup/server")
.unwrap()
.source,
"mcp_official"
);
assert_eq!(total_pages, 5);
}
@@ -195,11 +297,52 @@ mod tests {
("mcp_official", Err(anyhow::anyhow!("upstream 500"))),
("smithery", Ok((vec![summary("smi/only", "smithery")], 2))),
];
let (merged, total_pages) = merge_registry_results(results);
assert_eq!(merged.len(), 1);
assert_eq!(merged[0].qualified_name, "smi/only");
assert_eq!(total_pages, 2);
}
#[test]
fn apply_transport_filters_by_run_kind() {
let mut servers = vec![
SmitheryServerSummary {
is_deployed: true,
..summary("a/hosted", "mcp_official")
},
summary("b/stdio", "mcp_official"),
];
let mut hosted = servers.clone();
apply_transport(&mut hosted, Some("hosted"));
assert_eq!(hosted.len(), 1);
assert_eq!(hosted[0].qualified_name, "a/hosted");
apply_transport(&mut servers, Some("stdio"));
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].qualified_name, "b/stdio");
}
#[test]
fn refine_excludes_namespace_only_matches_but_never_empties() {
let real = SmitheryServerSummary {
display_name: "GitHub".to_string(),
description: Some("Official GitHub MCP server".to_string()),
..summary("io.github.github/github-mcp-server", "mcp_official")
};
let community = SmitheryServerSummary {
display_name: "medium-ops".to_string(),
description: Some("Medium CLI. No API keys.".to_string()),
..summary("io.github.06ketan/medium-ops", "mcp_official")
};
// "github" keeps the real one, drops the namespace-only community row.
let hits = refine_by_relevance(vec![real.clone(), community.clone()], "github");
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].qualified_name, "io.github.github/github-mcp-server");
// A query that matches nothing returns the unrefined rows (never blank).
let none = refine_by_relevance(vec![community.clone()], "github");
assert_eq!(none.len(), 1);
}
}
+8 -1
View File
@@ -148,6 +148,12 @@ pub fn schemas(function: &str) -> ControllerSchema {
comment: "Free-text search query.",
required: false,
},
FieldSchema {
name: "transport",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "Transport filter: \"stdio\", \"hosted\", or \"all\"/omitted.",
required: false,
},
FieldSchema {
name: "page",
ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
@@ -671,11 +677,12 @@ fn handle_registry_search(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let query = read_optional_string(&params, "query")?;
let transport = read_optional_string(&params, "transport")?;
let page = read_optional_u32(&params, "page")?;
let page_size = read_optional_u32(&params, "page_size")?;
to_json(
crate::openhuman::mcp_registry::ops::mcp_clients_registry_search(
&config, query, page, page_size,
&config, query, transport, page, page_size,
)
.await?,
)
+1 -1
View File
@@ -43,7 +43,7 @@ pub async fn mcp_setup_search(
let page = page.unwrap_or(1);
let page_size = page_size.unwrap_or(20);
let (servers, total_pages) =
registry::registry_search(config, query.as_deref(), page, page_size)
registry::registry_search(config, query.as_deref(), None, page, page_size)
.await
.map_err(|e| e.to_string())?;
Ok(RpcOutcome::new(
+9 -3
View File
@@ -56,14 +56,16 @@ impl Tool for McpRegistrySearchTool {
"mcp_registry_search"
}
fn description(&self) -> &str {
"Search the MCP server registry catalog by `query`, paginated by `page` \
/ `page_size`. Use to discover installable MCP servers."
"Search the MCP server registry catalog by `query`, optionally filtered by \
`transport` (\"stdio\" | \"hosted\" | \"all\"), paginated by `page` / \
`page_size`. Use to discover installable MCP servers."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"query": { "type": "string" },
"transport": { "type": "string", "enum": ["stdio", "hosted", "all"] },
"page": { "type": "integer", "minimum": 1 },
"page_size": { "type": "integer", "minimum": 1 }
}
@@ -79,8 +81,12 @@ impl Tool for McpRegistrySearchTool {
.get("page_size")
.and_then(Value::as_u64)
.map(|v| v as u32);
let transport = args
.get("transport")
.and_then(Value::as_str)
.map(str::to_string);
emit!(
ops::mcp_clients_registry_search(&self.config, query, page, page_size).await,
ops::mcp_clients_registry_search(&self.config, query, transport, page, page_size).await,
"mcp_registry_search"
)
}
+40
View File
@@ -253,6 +253,23 @@ pub struct SmitheryServerSummary {
/// the dispatcher; never trusted from the wire.
#[serde(default)]
pub official: bool,
/// Vendor/site URL declared by the server, when present. A trust/quality
/// signal: the strict catalog filter requires it and the UI renders it as a
/// clickable link. `None` for servers that declare no website (and for
/// Smithery summaries, which don't carry one). Set by the registry adapter;
/// never deserialized from the wire (`skip_deserializing`) so a payload that
/// starts emitting the key can't spoof the strict curation filter.
#[serde(default, skip_deserializing)]
pub website_url: Option<String>,
/// Declared auth method derived from registry metadata: `Some("api_key")`
/// when the server declares a named static secret (an `isSecret` /
/// `Authorization` header or an `isSecret` env var). `None` when no static
/// credential is declared (open, OAuth-only, or under-specified). Set by the
/// official adapter; never deserialized from the wire (`skip_deserializing`)
/// so a payload that starts emitting the key can't spoof the strict curation
/// filter.
#[serde(default, skip_deserializing)]
pub auth_kind: Option<String>,
/// Raw extra fields preserved for future use.
#[serde(flatten, default)]
pub extra: std::collections::HashMap<String, Value>,
@@ -501,6 +518,27 @@ mod tests {
assert!(s.is_deployed);
}
/// `website_url`/`auth_kind` are adapter-derived trust signals that drive the
/// strict "perfect server" filter and the UI. They must NEVER be honored from
/// the wire — `skip_deserializing` forces them to `None` on any parse so a
/// payload that starts emitting the keys can't spoof curation. Pins the
/// annotation so a future serde change can't silently re-admit them.
#[test]
fn smithery_summary_never_deserializes_trust_signals_from_the_wire() {
let raw = json!({
"qualifiedName": "@evil/server",
"displayName": "Evil",
"website_url": "https://spoofed.example",
"auth_kind": "api_key",
});
let s: SmitheryServerSummary = serde_json::from_value(raw).unwrap();
assert_eq!(
s.website_url, None,
"website_url must not come from the wire"
);
assert_eq!(s.auth_kind, None, "auth_kind must not come from the wire");
}
/// RPC responses to the frontend must use snake_case field names.
/// This pins the serialization format so a future serde annotation
/// change doesn't silently break the frontend.
@@ -515,6 +553,8 @@ mod tests {
is_deployed: true,
source: "mcp_official".to_string(),
official: false,
website_url: None,
auth_kind: None,
extra: Default::default(),
};
let v = serde_json::to_value(&s).unwrap();