diff --git a/app/src/components/channels/mcp/ConfigAssistantPanel.tsx b/app/src/components/channels/mcp/ConfigAssistantPanel.tsx index e80bca1e9..896574d99 100644 --- a/app/src/components/channels/mcp/ConfigAssistantPanel.tsx +++ b/app/src/components/channels/mcp/ConfigAssistantPanel.tsx @@ -34,8 +34,10 @@ export function clearConfigChat(qualifiedName: string): void { interface ConfigAssistantPanelProps { qualifiedName: string; onApplySuggestedEnv?: (env: Record) => 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) => { if (e.key === 'Enter' && !e.shiftKey) { @@ -136,9 +128,18 @@ const ConfigAssistantPanel = ({ {/* Message list */}
{messages.length === 0 && ( -

- {t('mcp.configAssistant.empty')} -

+
+

{t('mcp.configAssistant.empty')}

+ {autoPrompt && ( + + )} +
)} {messages.map((msg, idx) => (
{ 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( { 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( { 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' }); diff --git a/app/src/components/channels/mcp/InstallDialog.test.tsx b/app/src/components/channels/mcp/InstallDialog.test.tsx index 768f5ad13..1998ad107 100644 --- a/app/src/components/channels/mcp/InstallDialog.test.tsx +++ b/app/src/components/channels/mcp/InstallDialog.test.tsx @@ -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( + {}} 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 () => { diff --git a/app/src/components/channels/mcp/InstallDialog.tsx b/app/src/components/channels/mcp/InstallDialog.tsx index dd7ecbf64..8be4f7474 100644 --- a/app/src/components/channels/mcp/InstallDialog.tsx +++ b/app/src/components/channels/mcp/InstallDialog.tsx @@ -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
- {/* 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). */}
- {transport && ( - - {transport === 'stdio' - ? t('mcp.install.transportLocal') - : t('mcp.install.transportRemote')} - - )} + + {isHosted ? t('mcp.tab.transport.hosted') : t('mcp.tab.transport.local')} + {detail.use_count != null && detail.use_count > 0 && ( {t('mcp.install.useCount').replace('{count}', formatUseCount(detail.use_count))} )} - {detail.is_deployed && ( - - {t('mcp.install.deployed')} - - )} - {hasEnvKeys && ( - - {t('mcp.install.requiresConfig')} - - )}
{/* Description */} diff --git a/app/src/components/channels/mcp/McpServersTab.test.tsx b/app/src/components/channels/mcp/McpServersTab.test.tsx index 01f5dd817..55dac4aa6 100644 --- a/app/src/components/channels/mcp/McpServersTab.test.tsx +++ b/app/src/components/channels/mcp/McpServersTab.test.tsx @@ -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./ 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([]); diff --git a/app/src/components/channels/mcp/McpServersTab.tsx b/app/src/components/channels/mcp/McpServersTab.tsx index 66be10b15..ca4e36596 100644 --- a/app/src/components/channels/mcp/McpServersTab.tsx +++ b/app/src/components/channels/mcp/McpServersTab.tsx @@ -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 = { 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 = { - 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 ; - } - const isOfficial = source === 'mcp_official'; - return ( - - {t(labelKey)} - - ); -}; +/** 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./` (and + * `io.gitlab./…`), 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 ( {t(hosted ? 'mcp.tab.transport.hosted' : 'mcp.tab.transport.local')} ); }; +/** + * 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 }) => ( + +); + +/** + * 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 ( + 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); + } + }}> + +
+ {server.icon_url ? ( + + ) : ( + + 🔌 + + )} +
+ + {server.display_name} + {server.official && ( + + ✓ {t('mcp.tab.officialBadge')} + + )} + + {/* The registry is full of look-alike names (a dozen "gmail" + servers); the slug is the unique identifier that tells them + apart. */} + + {server.qualified_name} + + {server.description && ( + + {server.description} + + )} + {(server.website_url || repoUrl) && ( + + {server.website_url && ( + + )} + {repoUrl && } + + )} +
+
+ + + + + + {author ?? '—'} + + + + {t('mcp.install.button')} + + + + ); + } +); +CatalogRow.displayName = 'CatalogRow'; + const McpServersTab = () => { const { t } = useT(); const [servers, setServers] = useState([]); @@ -135,12 +262,17 @@ const McpServersTab = () => { // Unified search + filter const [searchQuery, setSearchQuery] = useState(''); const [activeChip, setActiveChip] = useState('all'); + // Secondary classification filter over catalog rows: by transport. + const [transportFilter, setTransportFilter] = useState<'all' | Transport>('all'); // Registry catalog results const [catalogServers, setCatalogServers] = useState([]); 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 | null>(null); const debounceRef = useRef | 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) => ( - handleSelectInstall(server.qualified_name)} - onKeyDown={e => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - handleSelectInstall(server.qualified_name); - } - }}> - -
- {server.icon_url ? ( - - ) : ( - - 🔌 - - )} -
- - - {server.display_name} - - {server.official && ( - - ✓ {t('mcp.tab.officialBadge')} - - )} - - - {/* The registry is full of look-alike names (a dozen "gmail" - servers); the slug is the unique identifier that tells them - apart. */} - - {server.qualified_name} - - {server.description && ( - - {server.description} - - )} -
-
- - - - - - - {deriveAuthor(server.qualified_name) ?? '—'} - - - - - {t('mcp.install.button')} - - - + // 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 => ( + + )), + [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 (
- {
- {/* Filter chips */} - - 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. */} +
+ + 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 && ( + <> +
{loadError && (
@@ -492,6 +633,18 @@ const McpServersTab = () => {
)} + {/* 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 && ( + + )} + {/* 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')} - {t('mcp.tab.column.source')} + {t('mcp.tab.column.type')} {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} + {/* 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 && ( +
+

{t('mcp.catalog.loadFailed')}

+ +
+ )} + {/* Empty states */} {activeChip === 'installed' && filteredInstalled.length === 0 && (
{ {t('mcp.installed.empty')}
)} - {activeChip === 'registry' && availableCatalog.length === 0 && !catalogLoading && ( -
- {searchQuery - ? t('mcp.catalog.noResultsFor').replace('{query}', searchQuery) - : t('mcp.catalog.noResults')} -
- )} + {activeChip === 'registry' && + availableCatalog.length === 0 && + !catalogLoading && + !catalogError && ( +
+ {searchQuery + ? t('mcp.catalog.noResultsFor').replace('{query}', searchQuery) + : t('mcp.catalog.noResults')} +
+ )} {activeChip === 'all' && filteredInstalled.length === 0 && availableCatalog.length === 0 && - !catalogLoading && ( + !catalogLoading && + !catalogError && (
diff --git a/app/src/components/channels/mcp/types.ts b/app/src/components/channels/mcp/types.ts index 07b56d339..54dd10ab1 100644 --- a/app/src/components/channels/mcp/types.ts +++ b/app/src/components/channels/mcp/types.ts @@ -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 = { diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index bddb61162..814e56378 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -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': 'تكوين وتثبيت', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 5c1e18f09..4dfc4546d 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -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': 'কনফিগার করুন ও ইনস্টল করুন', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 573aa4e95..c79d3a150 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -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', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 71186f555..ffab6472f 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -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', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 97da7e5ee..d4b3798a5 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -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', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 85321c979..62491c2cf 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -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', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index bba631c70..d29ade3b3 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -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': 'कॉन्फ़िगर करें और इंस्टॉल करें', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 7e18e7861..1fbd91340 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -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', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 8451b81e8..aefb611aa 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -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', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 62683a6e0..bd73dc88f 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -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': '구성 및 설치', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 4aeac42dc..7ec292f4d 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -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', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 965889a9d..712f77900 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -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', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index c493d116e..e364a78db 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -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': 'Настроить и установить', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index b56d2f7ce..6507ae8e5 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -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': '配置并安装', diff --git a/app/src/services/api/mcpClientsApi.ts b/app/src/services/api/mcpClientsApi.ts index ae9316789..0a631ee94 100644 --- a/app/src/services/api/mcpClientsApi.ts +++ b/app/src/services/api/mcpClientsApi.ts @@ -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 => { diff --git a/app/test/playwright/specs/mcp-tab-flow.spec.ts b/app/test/playwright/specs/mcp-tab-flow.spec.ts index 18985913a..60412df40 100644 --- a/app/test/playwright/specs/mcp-tab-flow.spec.ts +++ b/app/test/playwright/specs/mcp-tab-flow.spec.ts @@ -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 & { 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: [] }; diff --git a/docs/TEST-COVERAGE-MATRIX.md b/docs/TEST-COVERAGE-MATRIX.md index 1ff3db5f3..2e47a5970 100644 --- a/docs/TEST-COVERAGE-MATRIX.md +++ b/docs/TEST-COVERAGE-MATRIX.md @@ -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`) | @@ -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 | --- diff --git a/src/openhuman/mcp_registry/curation.rs b/src/openhuman/mcp_registry/curation.rs index 1601f1995..cadfc0b65 100644 --- a/src/openhuman/mcp_registry/curation.rs +++ b/src/openhuman/mcp_registry/curation.rs @@ -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) -> 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"]); + } } diff --git a/src/openhuman/mcp_registry/ops.rs b/src/openhuman/mcp_registry/ops.rs index 455e25f5e..4d8921c5e 100644 --- a/src/openhuman/mcp_registry/ops.rs +++ b/src/openhuman/mcp_registry/ops.rs @@ -23,6 +23,7 @@ use super::types::{CommandKind, ConnStatus, InstalledServer}; pub async fn mcp_clients_registry_search( config: &Config, query: Option, + transport: Option, page: Option, page_size: Option, ) -> Result, 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 }), diff --git a/src/openhuman/mcp_registry/registries/mcp_official.rs b/src/openhuman/mcp_registry/registries/mcp_official.rs index 64d576e73..a3d07744a 100644 --- a/src/openhuman/mcp_registry/registries/mcp_official.rs +++ b/src/openhuman/mcp_registry/registries/mcp_official.rs @@ -563,9 +563,40 @@ struct OfficialServer { /// Installable subprocess packages (npm, pip, brew, …). #[serde(default)] packages: Vec, + /// 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, } impl OfficialServer { + /// Non-empty declared `websiteUrl`, if any. + fn website(&self) -> Option { + 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": [] }); diff --git a/src/openhuman/mcp_registry/registries/smithery.rs b/src/openhuman/mcp_registry/registries/smithery.rs index 5da97904c..e423da344 100644 --- a/src/openhuman/mcp_registry/registries/smithery.rs +++ b/src/openhuman/mcp_registry/registries/smithery.rs @@ -183,6 +183,15 @@ fn tag_source(mut servers: Vec) -> Vec 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, 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, 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, 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./*` 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, + query: &str, +) -> Vec { + let tokens: Vec = query + .to_lowercase() + .split_whitespace() + .map(String::from) + .collect(); + if tokens.is_empty() { + return servers; + } + let refined: Vec = 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, u32)>)>, -) -> (Vec, 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, u32) { let mut merged: Vec = Vec::new(); let mut seen: HashSet = 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); + } } diff --git a/src/openhuman/mcp_registry/schemas.rs b/src/openhuman/mcp_registry/schemas.rs index 3d8218ca3..978b1352b 100644 --- a/src/openhuman/mcp_registry/schemas.rs +++ b/src/openhuman/mcp_registry/schemas.rs @@ -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) -> ControllerFuture { Box::pin(async move { let config = config_rpc::load_config_with_timeout().await?; let query = read_optional_string(¶ms, "query")?; + let transport = read_optional_string(¶ms, "transport")?; let page = read_optional_u32(¶ms, "page")?; let page_size = read_optional_u32(¶ms, "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?, ) diff --git a/src/openhuman/mcp_registry/setup_ops.rs b/src/openhuman/mcp_registry/setup_ops.rs index a3a9497f8..8df09d128 100644 --- a/src/openhuman/mcp_registry/setup_ops.rs +++ b/src/openhuman/mcp_registry/setup_ops.rs @@ -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( diff --git a/src/openhuman/mcp_registry/tools.rs b/src/openhuman/mcp_registry/tools.rs index f900739d6..a2698a14c 100644 --- a/src/openhuman/mcp_registry/tools.rs +++ b/src/openhuman/mcp_registry/tools.rs @@ -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" ) } diff --git a/src/openhuman/mcp_registry/types.rs b/src/openhuman/mcp_registry/types.rs index 232ff173e..7aae8a567 100644 --- a/src/openhuman/mcp_registry/types.rs +++ b/src/openhuman/mcp_registry/types.rs @@ -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, + /// 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, /// Raw extra fields preserved for future use. #[serde(flatten, default)] pub extra: std::collections::HashMap, @@ -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();