feat(mcp-registry): redesign MCP tab with official registry as primary source (#3480)

This commit is contained in:
Steven Enamakel
2026-06-07 21:22:29 -07:00
committed by GitHub
parent 912e092148
commit 3ae5bab787
35 changed files with 2293 additions and 513 deletions
@@ -1,5 +1,5 @@
/**
* Install dialog for a Smithery server.
* Install dialog for an MCP server.
* Fetches the server detail, renders env-key inputs (password type with
* show/hide toggle), an optional raw-JSON config textarea, and calls
* `install` on submit.
@@ -23,14 +23,14 @@ describe('McpCatalogBrowser', () => {
it('renders search input', async () => {
mockRegistrySearch.mockResolvedValue({ servers: [], page: 1, total_pages: 1 });
render(<McpCatalogBrowser onSelectInstall={() => {}} />);
expect(screen.getByPlaceholderText('Search Smithery catalog...')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Search MCP servers...')).toBeInTheDocument();
});
it('fires debounced search on input change', async () => {
mockRegistrySearch.mockResolvedValue({ servers: [], page: 1, total_pages: 1 });
render(<McpCatalogBrowser onSelectInstall={() => {}} />);
const input = screen.getByPlaceholderText('Search Smithery catalog...');
const input = screen.getByPlaceholderText('Search MCP servers...');
// Advance past the initial debounce
await act(async () => {
@@ -122,7 +122,7 @@ describe('McpCatalogBrowser', () => {
// Should show empty/no-results state, not crash
await waitFor(() => {
expect(screen.getByPlaceholderText('Search Smithery catalog...')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Search MCP servers...')).toBeInTheDocument();
});
// No "Install" button — nothing to install from an undefined list
expect(screen.queryByRole('button', { name: 'Install' })).not.toBeInTheDocument();
@@ -138,7 +138,7 @@ describe('McpCatalogBrowser', () => {
vi.useRealTimers();
await waitFor(() => {
expect(screen.getByPlaceholderText('Search Smithery catalog...')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Search MCP servers...')).toBeInTheDocument();
});
expect(screen.queryByRole('button', { name: 'Install' })).not.toBeInTheDocument();
});
@@ -1,5 +1,5 @@
/**
* Smithery registry browser with debounced search and pagination.
* MCP server catalog browser with debounced search and pagination.
* Clicking "Install" on a card opens the InstallDialog flow.
*/
import debug from 'debug';
@@ -7,7 +7,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import { mcpClientsApi } from '../../../services/api/mcpClientsApi';
import SmitheryServerCard from './SmitheryServerCard';
import McpServerCard from './McpServerCard';
import type { SmitheryServer } from './types';
const log = debug('mcp-clients:catalog');
@@ -116,7 +116,7 @@ const McpCatalogBrowser = ({ onSelectInstall }: McpCatalogBrowserProps) => {
<>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{servers.map(server => (
<SmitheryServerCard
<McpServerCard
key={server.qualified_name}
server={server}
onInstall={onSelectInstall}
@@ -1,16 +1,16 @@
/**
* Card component for a single Smithery registry server.
* Card component for a single MCP registry server.
* Shows icon, name, description (clamped), usage count and deployed badge.
*/
import { useT } from '../../../lib/i18n/I18nContext';
import type { SmitheryServer } from './types';
interface SmitheryServerCardProps {
interface McpServerCardProps {
server: SmitheryServer;
onInstall: (qualifiedName: string) => void;
}
const SmitheryServerCard = ({ server, onInstall }: SmitheryServerCardProps) => {
const McpServerCard = ({ server, onInstall }: McpServerCardProps) => {
const { t } = useT();
return (
<div className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-3 flex flex-col gap-2">
@@ -61,4 +61,4 @@ const SmitheryServerCard = ({ server, onInstall }: SmitheryServerCardProps) => {
);
};
export default SmitheryServerCard;
export default McpServerCard;
@@ -1,7 +1,9 @@
/**
* Tests for McpServersTab — the top-level two-pane MCP servers view.
* Covers the major flows: initial load, error state, pane transitions,
* install success, uninstall, and status polling.
* Tests for McpServersTab — unified table view.
*
* Covers: initial load, error state, filter chips, table rows,
* navigation to detail/install views, install success, uninstall, and
* status polling.
*/
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -69,15 +71,27 @@ const STATUSES_CONNECTED = [
},
];
const STATUSES_ERROR = [
{
server_id: 'srv-1',
qualified_name: 'acme/fs-server',
display_name: 'File Server',
status: 'error' as const,
tool_count: 0,
},
];
/**
* Helper that renders McpServersTab and waits past the initial debounce
* so the catalog fetch fires and resolves. Keeps fake timers throughout —
* callers that need waitFor must switch to real timers themselves.
*/
async function renderAndWaitForLoad() {
const result = render(<McpServersTab />);
// Drain resolved promises from installedList / status
await act(async () => {
await Promise.resolve();
});
// Advance past the 300 ms catalog debounce so fetchCatalog is called
await act(async () => {
await vi.advanceTimersByTimeAsync(300);
});
// Drain the async catalog fetch result
await act(async () => {
await Promise.resolve();
});
return result;
}
describe('McpServersTab', () => {
beforeEach(() => {
@@ -92,6 +106,7 @@ describe('McpServersTab', () => {
mockSetEnabled.mockReset();
mockRegistryGet.mockReset();
mockRegistrySearch.mockReset();
mockRegistrySearch.mockResolvedValue({ servers: [], page: 1, total_pages: 1 });
});
afterEach(() => {
@@ -105,26 +120,50 @@ describe('McpServersTab', () => {
expect(screen.getByText('Loading MCP servers...')).toBeInTheDocument();
});
it('renders installed server list after load', async () => {
it('renders installed server row in the table after load', async () => {
mockInstalledList.mockResolvedValue(SERVERS);
mockStatus.mockResolvedValue(STATUSES_DISCONNECTED);
render(<McpServersTab />);
await renderAndWaitForLoad();
vi.useRealTimers();
await waitFor(() => {
expect(screen.getByText('File Server')).toBeInTheDocument();
});
expect(screen.getByText('Browse catalog')).toBeInTheDocument();
// Table columns are present
expect(screen.getByText('Name')).toBeInTheDocument();
expect(screen.getByText('Source')).toBeInTheDocument();
});
it('shows empty state when no servers installed', async () => {
it('renders filter chips — All, Installed, Registry', async () => {
mockInstalledList.mockResolvedValue([]);
mockStatus.mockResolvedValue([]);
render(<McpServersTab />);
await renderAndWaitForLoad();
vi.useRealTimers();
await waitFor(() => {
expect(screen.queryByText('Loading MCP servers...')).not.toBeInTheDocument();
});
expect(screen.getByRole('button', { name: 'All' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Installed/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Registry' })).toBeInTheDocument();
});
it('shows empty-installed state when Installed chip is active and no servers', async () => {
mockInstalledList.mockResolvedValue([]);
mockStatus.mockResolvedValue([]);
await renderAndWaitForLoad();
vi.useRealTimers();
await waitFor(() => {
expect(screen.queryByText('Loading MCP servers...')).not.toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: /Installed/i }));
await waitFor(() => {
expect(screen.getByText('No MCP servers installed yet.')).toBeInTheDocument();
});
@@ -134,7 +173,7 @@ describe('McpServersTab', () => {
mockInstalledList.mockRejectedValue(new Error('DB error'));
mockStatus.mockResolvedValue([]);
render(<McpServersTab />);
await renderAndWaitForLoad();
vi.useRealTimers();
await waitFor(() => {
@@ -142,216 +181,80 @@ describe('McpServersTab', () => {
});
});
it('shows placeholder in right pane when no server selected', async () => {
mockInstalledList.mockResolvedValue(SERVERS);
mockStatus.mockResolvedValue(STATUSES_DISCONNECTED);
it('shows Inventory button in the header', async () => {
mockInstalledList.mockResolvedValue([]);
mockStatus.mockResolvedValue([]);
render(<McpServersTab />);
await renderAndWaitForLoad();
vi.useRealTimers();
await waitFor(() => screen.getByText('File Server'));
expect(screen.getByText('Select a server or browse the catalog.')).toBeInTheDocument();
});
it('refreshes installed list + status after a server is disabled from the detail pane', async () => {
mockInstalledList.mockResolvedValue(SERVERS);
mockStatus.mockResolvedValue(STATUSES_DISCONNECTED);
mockSetEnabled.mockResolvedValue({ server_id: 'srv-1', enabled: false });
render(<McpServersTab />);
vi.useRealTimers();
await waitFor(() => screen.getByText('File Server'));
fireEvent.click(screen.getAllByRole('button', { name: /File Server/i })[0]);
await waitFor(() => screen.getByText('acme/fs-server'));
const installedCallsBefore = mockInstalledList.mock.calls.length;
const statusCallsBefore = mockStatus.mock.calls.length;
const disableBtn = await screen.findByRole('button', { name: /^disable$/i });
fireEvent.click(disableBtn);
// After the toggle, handleEnabledChange must re-call both loadInstalled
// and fetchStatuses so the parent reflects the new enabled state.
await waitFor(() => {
expect(mockSetEnabled).toHaveBeenCalledWith('srv-1', false);
expect(mockInstalledList.mock.calls.length).toBeGreaterThan(installedCallsBefore);
expect(mockStatus.mock.calls.length).toBeGreaterThan(statusCallsBefore);
expect(screen.queryByText('Loading MCP servers...')).not.toBeInTheDocument();
});
expect(
screen.getByRole('button', { name: 'Open the sharable MCP inventory panel' })
).toBeInTheDocument();
});
it('opens detail pane when a server is clicked', async () => {
it('navigates to detail view when an installed server row is clicked', async () => {
mockInstalledList.mockResolvedValue(SERVERS);
mockStatus.mockResolvedValue(STATUSES_DISCONNECTED);
render(<McpServersTab />);
await renderAndWaitForLoad();
vi.useRealTimers();
await waitFor(() => screen.getByText('File Server'));
fireEvent.click(screen.getAllByRole('button', { name: /File Server/i })[0]);
// Click the table row (not a button)
fireEvent.click(screen.getByText('File Server').closest('tr')!);
await waitFor(() => {
expect(screen.getByText('acme/fs-server')).toBeInTheDocument();
});
});
it('opens catalog browser when Browse catalog is clicked', async () => {
mockInstalledList.mockResolvedValue([]);
mockStatus.mockResolvedValue([]);
mockRegistrySearch.mockResolvedValue({ servers: [], page: 1, total_pages: 1 });
it('navigates back to home from detail view', async () => {
mockInstalledList.mockResolvedValue(SERVERS);
mockStatus.mockResolvedValue(STATUSES_DISCONNECTED);
render(<McpServersTab />);
await renderAndWaitForLoad();
vi.useRealTimers();
await waitFor(() => screen.getByText('No MCP servers installed yet.'));
await waitFor(() => screen.getByText('File Server'));
fireEvent.click(screen.getByText('File Server').closest('tr')!);
// advance past debounce in McpCatalogBrowser
await act(async () => {
await vi.advanceTimersByTimeAsync?.(300).catch(() => {});
});
fireEvent.click(screen.getAllByRole('button', { name: 'Browse catalog' })[0]);
await waitFor(() => screen.getByText('acme/fs-server'));
// Back button navigates home
fireEvent.click(screen.getByText('Go back'));
await waitFor(() => {
expect(screen.getByPlaceholderText('Search Smithery catalog...')).toBeInTheDocument();
expect(screen.queryByText('acme/fs-server')).not.toBeInTheDocument();
expect(screen.getByText('File Server')).toBeInTheDocument();
});
});
it('clears load error on successful reload after failure', async () => {
// Initial load fails (transient error banner appears), then a successful
// install triggers loadInstalled() — the banner must be cleared on success.
mockInstalledList.mockRejectedValueOnce(new Error('Transient error'));
it('shows registry servers from catalog in the table', async () => {
mockInstalledList.mockResolvedValue([]);
mockStatus.mockResolvedValue([]);
mockRegistrySearch.mockResolvedValue({
servers: [{ qualified_name: 'acme/new-srv', display_name: 'New Server' }],
servers: [
{ qualified_name: 'acme/new-srv', display_name: 'New Server', description: 'A new server' },
],
page: 1,
total_pages: 1,
});
render(<McpServersTab />);
vi.useRealTimers();
await waitFor(() => screen.getByText('Transient error'));
// Drive an install flow that triggers loadInstalled() on success.
const detail = {
qualified_name: 'acme/new-srv',
display_name: 'New Server',
description: null,
connections: [],
required_env_keys: [],
};
const newServer = { ...SERVERS[0], server_id: 'srv-new', qualified_name: 'acme/new-srv' };
mockRegistryGet.mockResolvedValue(detail);
mockInstall.mockResolvedValue(newServer);
mockInstalledList.mockResolvedValue([newServer]); // reload after install succeeds
fireEvent.click(screen.getAllByRole('button', { name: 'Browse catalog' })[0]);
await act(async () => {
await vi.advanceTimersByTimeAsync?.(300).catch(() => {});
});
await waitFor(() => screen.getByText('New Server'));
fireEvent.click(screen.getByRole('button', { name: 'Install' }));
await waitFor(() => screen.getByRole('button', { name: 'Install' }));
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Install' }));
});
// After successful reload, the error banner must be gone.
await waitFor(() => {
expect(screen.queryByText('Transient error')).not.toBeInTheDocument();
});
});
// -----------------------------------------------------------------------
// Regression: malformed RPC envelopes must not crash the tab
// (Commit 38fcbd8f5 — `Cannot read properties of undefined (reading 'find')`)
// -----------------------------------------------------------------------
it('renders empty state when installedList resolves with undefined installed field', async () => {
// Simulates core returning `{}` on first launch before MCP store is init'd.
// The api layer now returns [] in this case; this test verifies the full path.
mockInstalledList.mockResolvedValue([]);
mockStatus.mockResolvedValue([]);
render(<McpServersTab />);
await renderAndWaitForLoad();
vi.useRealTimers();
await waitFor(() => {
expect(screen.getByText('No MCP servers installed yet.')).toBeInTheDocument();
expect(screen.getByText('New Server')).toBeInTheDocument();
});
// Install button present for registry server
expect(screen.getByRole('button', { name: 'Install' })).toBeInTheDocument();
});
it('does not crash when installedList resolves with null', async () => {
// If mcpClientsApi.installedList ever passes through null (belt + suspenders).
mockInstalledList.mockResolvedValue(null as unknown as never[]);
mockStatus.mockResolvedValue([]);
// Should not throw
const { container } = render(<McpServersTab />);
vi.useRealTimers();
// Either shows empty state or loading — but does NOT crash
await waitFor(() => {
expect(container).toBeTruthy();
});
});
it('does not crash when status resolves with undefined', async () => {
mockInstalledList.mockResolvedValue(SERVERS);
mockStatus.mockResolvedValue(undefined as unknown as never[]);
render(<McpServersTab />);
vi.useRealTimers();
// Server row still renders; just no status badge data
await waitFor(() => {
expect(screen.getByText('File Server')).toBeInTheDocument();
});
});
it('shows error banner when installedList rejects, not a crash', async () => {
mockInstalledList.mockRejectedValue(new Error('RPC timeout'));
mockStatus.mockResolvedValue([]);
render(<McpServersTab />);
vi.useRealTimers();
await waitFor(() => {
expect(screen.getByText('RPC timeout')).toBeInTheDocument();
});
// Loading state should be gone
expect(screen.queryByText('Loading MCP servers...')).not.toBeInTheDocument();
});
it('server row renders even when status rejects', async () => {
mockInstalledList.mockResolvedValue(SERVERS);
mockStatus.mockRejectedValue(new Error('status unavailable'));
render(<McpServersTab />);
vi.useRealTimers();
// Tab should still show the server list; status error is non-fatal
await waitFor(() => {
expect(screen.getByText('File Server')).toBeInTheDocument();
});
});
it('shows tool count badge when connected', async () => {
mockInstalledList.mockResolvedValue(SERVERS);
mockStatus.mockResolvedValue(STATUSES_CONNECTED);
render(<McpServersTab />);
vi.useRealTimers();
await waitFor(() => {
expect(screen.getByText('2 tools')).toBeInTheDocument();
});
});
it('opens install dialog from catalog browser', async () => {
it('navigates to install view when Install is clicked on a registry server', async () => {
mockInstalledList.mockResolvedValue([]);
mockStatus.mockResolvedValue([]);
mockRegistrySearch.mockResolvedValue({
@@ -361,16 +264,9 @@ describe('McpServersTab', () => {
});
mockRegistryGet.mockReturnValue(new Promise(() => {}));
render(<McpServersTab />);
await renderAndWaitForLoad();
vi.useRealTimers();
await waitFor(() => screen.getByText('No MCP servers installed yet.'));
fireEvent.click(screen.getAllByRole('button', { name: 'Browse catalog' })[0]);
await act(async () => {
await vi.advanceTimersByTimeAsync?.(300).catch(() => {});
});
await waitFor(() => screen.getByText('New Server'));
fireEvent.click(screen.getByRole('button', { name: 'Install' }));
@@ -379,7 +275,7 @@ describe('McpServersTab', () => {
});
});
it('returns to catalog after install cancel', async () => {
it('returns to home after install cancel', async () => {
mockInstalledList.mockResolvedValue([]);
mockStatus.mockResolvedValue([]);
mockRegistrySearch.mockResolvedValue({
@@ -396,16 +292,9 @@ describe('McpServersTab', () => {
};
mockRegistryGet.mockResolvedValue(detail);
render(<McpServersTab />);
await renderAndWaitForLoad();
vi.useRealTimers();
await waitFor(() => screen.getByText('No MCP servers installed yet.'));
fireEvent.click(screen.getAllByRole('button', { name: 'Browse catalog' })[0]);
await act(async () => {
await vi.advanceTimersByTimeAsync?.(300).catch(() => {});
});
await waitFor(() => screen.getByText('New Server'));
fireEvent.click(screen.getByRole('button', { name: 'Install' }));
await waitFor(() => screen.getByRole('button', { name: 'Cancel' }));
@@ -413,7 +302,8 @@ describe('McpServersTab', () => {
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
await waitFor(() => {
expect(screen.getByPlaceholderText('Search Smithery catalog...')).toBeInTheDocument();
// Back at home — search input is present
expect(screen.getByPlaceholderText('Search MCP servers...')).toBeInTheDocument();
});
});
@@ -445,7 +335,6 @@ describe('McpServersTab', () => {
};
mockRegistryGet.mockResolvedValue(detail);
mockInstall.mockResolvedValue(newServer);
// After install, list refresh returns new server
mockInstalledList.mockResolvedValueOnce([]).mockResolvedValue([newServer]);
mockStatus.mockResolvedValue([
{
@@ -457,16 +346,9 @@ describe('McpServersTab', () => {
},
]);
render(<McpServersTab />);
await renderAndWaitForLoad();
vi.useRealTimers();
await waitFor(() => screen.getByText('No MCP servers installed yet.'));
fireEvent.click(screen.getAllByRole('button', { name: 'Browse catalog' })[0]);
await act(async () => {
await vi.advanceTimersByTimeAsync?.(300).catch(() => {});
});
await waitFor(() => screen.getByText('New Server'));
fireEvent.click(screen.getByRole('button', { name: 'Install' }));
@@ -480,50 +362,168 @@ describe('McpServersTab', () => {
});
});
it('surfaces a partial-failure alert when bulk Retry All has rejections', async () => {
it('refreshes installed list + status after a server is disabled from the detail pane', async () => {
mockInstalledList.mockResolvedValue(SERVERS);
mockStatus.mockResolvedValue(STATUSES_ERROR);
// The single errored server fails to reconnect.
mockConnect.mockRejectedValue(new Error('connect refused'));
mockStatus.mockResolvedValue(STATUSES_DISCONNECTED);
mockSetEnabled.mockResolvedValue({ server_id: 'srv-1', enabled: false });
render(<McpServersTab />);
await renderAndWaitForLoad();
vi.useRealTimers();
const retryBtn = await screen.findByRole('button', { name: 'Retry all 1 errored MCP servers' });
await act(async () => {
fireEvent.click(retryBtn);
});
await waitFor(() => screen.getByText('File Server'));
fireEvent.click(screen.getByText('File Server').closest('tr')!);
await waitFor(() => screen.getByText('acme/fs-server'));
const installedCallsBefore = mockInstalledList.mock.calls.length;
const statusCallsBefore = mockStatus.mock.calls.length;
const disableBtn = await screen.findByRole('button', { name: /^disable$/i });
fireEvent.click(disableBtn);
// allSettled never rejects, so the connect was attempted...
await waitFor(() => expect(mockConnect).toHaveBeenCalledWith('srv-1'));
// ...and the failure is surfaced through the alert region, not swallowed.
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('1 of 1 servers failed. See logs.');
expect(mockSetEnabled).toHaveBeenCalledWith('srv-1', false);
expect(mockInstalledList.mock.calls.length).toBeGreaterThan(installedCallsBefore);
expect(mockStatus.mock.calls.length).toBeGreaterThan(statusCallsBefore);
});
});
it('surfaces a partial-failure alert when bulk Disconnect All has rejections', async () => {
mockInstalledList.mockResolvedValue(SERVERS);
mockStatus.mockResolvedValue(STATUSES_CONNECTED);
mockDisconnect.mockRejectedValue(new Error('disconnect failed'));
it('clears load error on successful reload after failure', async () => {
mockInstalledList.mockRejectedValueOnce(new Error('Transient error'));
mockStatus.mockResolvedValue([]);
mockRegistrySearch.mockResolvedValue({
servers: [{ qualified_name: 'acme/new-srv', display_name: 'New Server' }],
page: 1,
total_pages: 1,
});
render(<McpServersTab />);
await renderAndWaitForLoad();
vi.useRealTimers();
const disconnectBtn = await screen.findByRole('button', {
name: 'Disconnect all 1 connected MCP servers',
});
fireEvent.click(disconnectBtn);
await waitFor(() => screen.getByText('Transient error'));
// Confirm dialog gates the bulk RPC; confirm it.
const confirmBtn = await screen.findByRole('button', { name: 'Disconnect all' });
const detail = {
qualified_name: 'acme/new-srv',
display_name: 'New Server',
description: null,
connections: [],
required_env_keys: [],
};
const newServer = { ...SERVERS[0], server_id: 'srv-new', qualified_name: 'acme/new-srv' };
mockRegistryGet.mockResolvedValue(detail);
mockInstall.mockResolvedValue(newServer);
mockInstalledList.mockResolvedValue([newServer]);
await waitFor(() => screen.getByText('New Server'));
fireEvent.click(screen.getByRole('button', { name: 'Install' }));
await waitFor(() => screen.getByRole('button', { name: 'Install' }));
await act(async () => {
fireEvent.click(confirmBtn);
fireEvent.click(screen.getByRole('button', { name: 'Install' }));
});
await waitFor(() => expect(mockDisconnect).toHaveBeenCalledWith('srv-1'));
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('1 of 1 servers failed. See logs.');
expect(screen.queryByText('Transient error')).not.toBeInTheDocument();
});
});
// -----------------------------------------------------------------------
// Regression: malformed RPC envelopes must not crash the tab
// -----------------------------------------------------------------------
it('renders without crashing when installedList resolves with undefined/null', async () => {
mockInstalledList.mockResolvedValue(null as unknown as never[]);
mockStatus.mockResolvedValue([]);
const { container } = await renderAndWaitForLoad();
vi.useRealTimers();
await waitFor(() => {
expect(container).toBeTruthy();
});
});
it('does not crash when status resolves with undefined', async () => {
mockInstalledList.mockResolvedValue(SERVERS);
mockStatus.mockResolvedValue(undefined as unknown as never[]);
await renderAndWaitForLoad();
vi.useRealTimers();
await waitFor(() => {
expect(screen.getByText('File Server')).toBeInTheDocument();
});
});
it('shows error banner when installedList rejects, not a crash', async () => {
mockInstalledList.mockRejectedValue(new Error('RPC timeout'));
mockStatus.mockResolvedValue([]);
await renderAndWaitForLoad();
vi.useRealTimers();
await waitFor(() => {
expect(screen.getByText('RPC timeout')).toBeInTheDocument();
});
expect(screen.queryByText('Loading MCP servers...')).not.toBeInTheDocument();
});
it('server row renders even when status rejects', async () => {
mockInstalledList.mockResolvedValue(SERVERS);
mockStatus.mockRejectedValue(new Error('status unavailable'));
await renderAndWaitForLoad();
vi.useRealTimers();
await waitFor(() => {
expect(screen.getByText('File Server')).toBeInTheDocument();
});
});
it('shows installed server with Installed badge in the table', async () => {
mockInstalledList.mockResolvedValue(SERVERS);
mockStatus.mockResolvedValue(STATUSES_CONNECTED);
await renderAndWaitForLoad();
vi.useRealTimers();
await waitFor(() => {
expect(screen.getByText('File Server')).toBeInTheDocument();
});
// Installed badge is shown for installed servers
expect(screen.getByText('Installed')).toBeInTheDocument();
// Manage link is shown
expect(screen.getByText('Manage')).toBeInTheDocument();
});
it('search query narrows the installed server table results', async () => {
mockInstalledList.mockResolvedValue(SERVERS);
mockStatus.mockResolvedValue(STATUSES_DISCONNECTED);
await renderAndWaitForLoad();
vi.useRealTimers();
await waitFor(() => screen.getByText('File Server'));
const searchInput = screen.getByRole('searchbox');
fireEvent.change(searchInput, { target: { value: 'zzz-nomatch' } });
await waitFor(() => {
expect(screen.queryByText('File Server')).not.toBeInTheDocument();
});
});
it('Registry chip hides installed rows', async () => {
mockInstalledList.mockResolvedValue(SERVERS);
mockStatus.mockResolvedValue(STATUSES_DISCONNECTED);
await renderAndWaitForLoad();
vi.useRealTimers();
await waitFor(() => screen.getByText('File Server'));
fireEvent.click(screen.getByRole('button', { name: 'Registry' }));
await waitFor(() => {
expect(screen.queryByText('File Server')).not.toBeInTheDocument();
});
});
});
+354 -166
View File
@@ -1,8 +1,9 @@
/**
* Top-level MCP Servers tab component.
* Two-pane layout: left = InstalledServerList + browse button,
* right = selected server detail OR catalog browser OR install dialog.
* Polls `status` every 5s while any server is connected.
* Top-level MCP Servers tab.
*
* Unified table view: shows both installed servers and registry catalog
* results in a single table. Filter chips at the top let users toggle
* between "All", "Installed", and "Registry" views.
*/
import debug from 'debug';
import { useCallback, useEffect, useRef, useState } from 'react';
@@ -11,74 +12,111 @@ import { useT } from '../../../lib/i18n/I18nContext';
import { mcpClientsApi } from '../../../services/api/mcpClientsApi';
import InstallDialog from './InstallDialog';
import InstalledServerDetail from './InstalledServerDetail';
import InstalledServerList from './InstalledServerList';
import McpCatalogBrowser from './McpCatalogBrowser';
import McpConnectionHealthToolbar from './McpConnectionHealthToolbar';
import McpInventoryPanel from './McpInventoryPanel';
import McpServerSearch from './McpServerSearch';
import type { ConnStatus, InstalledServer } from './types';
import type { ConnStatus, InstalledServer, ServerStatus, SmitheryServer } from './types';
const log = debug('mcp-clients:tab');
const POLL_INTERVAL_MS = 5_000;
const DEBOUNCE_MS = 300;
const PAGE_SIZE = 30;
type RightPane =
| { mode: 'none' }
type View =
| { mode: 'home' }
| { mode: 'detail'; serverId: string }
| { mode: 'catalog' }
| { mode: 'install'; qualifiedName: string; prefillEnv?: Record<string, string> };
type FilterChip = 'all' | 'installed' | 'registry';
const STATUS_DOT: Record<ServerStatus, string> = {
connected: 'bg-sage-500',
connecting: 'bg-amber-400',
disconnected: 'bg-stone-300 dark:bg-neutral-600',
error: 'bg-coral-500',
disabled: 'bg-stone-200 dark:bg-neutral-700',
};
const McpServersTab = () => {
const { t } = useT();
const [servers, setServers] = useState<InstalledServer[]>([]);
const [statuses, setStatuses] = useState<ConnStatus[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [rightPane, setRightPane] = useState<RightPane>({ mode: 'none' });
// Local-only filter for the installed-server list. Not persisted — the
// search is a transient scan helper, not a saved view.
const [searchFilter, setSearchFilter] = useState('');
// Sharable Inventory modal toggle. Local state — the manifest UX is
// a one-off interaction, not a saved view.
const [view, setView] = useState<View>({ mode: 'home' });
const [inventoryOpen, setInventoryOpen] = useState(false);
// Unified search + filter
const [searchQuery, setSearchQuery] = useState('');
const [activeChip, setActiveChip] = useState<FilterChip>('all');
// Registry catalog results
const [catalogServers, setCatalogServers] = useState<SmitheryServer[]>([]);
const [catalogLoading, setCatalogLoading] = useState(false);
const [catalogPage, setCatalogPage] = useState(1);
const [catalogTotalPages, setCatalogTotalPages] = useState(1);
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const requestSeqRef = useRef(0);
const loadInstalled = useCallback(async () => {
log('loading installed servers');
try {
const installed = await mcpClientsApi.installedList();
// Defensive: API contract guarantees an array, but if a future regression
// or malformed envelope returns `undefined`, downstream `.find` crashes
// the entire tab. Normalise here.
setServers(Array.isArray(installed) ? installed : []);
// Clear any previous error on successful reload.
setLoadError(null);
log('loaded %d installed servers', installed.length);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to load installed servers';
log('load error: %s', msg);
setLoadError(msg);
}
}, []);
const fetchStatuses = useCallback(async () => {
log('polling statuses');
try {
const sv = await mcpClientsApi.status();
// Defensive: same reasoning as `loadInstalled` — `.find` / `.map`
// downstream cannot tolerate an undefined array.
setStatuses(Array.isArray(sv) ? sv : []);
} catch (err) {
log('status poll error: %o', err);
}
}, []);
// Initial load — `loading` starts as `true` so no synchronous setState
// before the async work is needed; just kick off the loads and clear on done.
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 => (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);
}
}, []);
useEffect(() => {
Promise.all([loadInstalled(), fetchStatuses()]).finally(() => setLoading(false));
}, [loadInstalled, fetchStatuses]);
// Poll status every 5s while at least one server is connected.
// Fetch catalog on mount and when search changes
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
void fetchCatalog(searchQuery, 1, false);
}, DEBOUNCE_MS);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [searchQuery, fetchCatalog]);
// Poll status
useEffect(() => {
const hasConnected = statuses.some(s => s.status === 'connected');
if (!hasConnected) {
@@ -88,7 +126,6 @@ const McpServersTab = () => {
}
return;
}
const schedule = () => {
pollTimerRef.current = setTimeout(async () => {
await fetchStatuses();
@@ -96,7 +133,6 @@ const McpServersTab = () => {
}, POLL_INTERVAL_MS);
};
schedule();
return () => {
if (pollTimerRef.current) {
clearTimeout(pollTimerRef.current);
@@ -106,99 +142,64 @@ const McpServersTab = () => {
}, [statuses, fetchStatuses]);
const handleSelectServer = useCallback((serverId: string) => {
log('selected server_id=%s', serverId);
setRightPane({ mode: 'detail', serverId });
}, []);
const handleBrowseCatalog = useCallback(() => {
log('opening catalog browser');
setRightPane({ mode: 'catalog' });
setView({ mode: 'detail', serverId });
}, []);
const handleSelectInstall = useCallback((qualifiedName: string) => {
log('opening install dialog for %s', qualifiedName);
setRightPane({ mode: 'install', qualifiedName });
setView({ mode: 'install', qualifiedName });
}, []);
const handleInstallSuccess = useCallback(
async (server: InstalledServer) => {
log('install success server_id=%s, refreshing list', server.server_id);
await loadInstalled();
await fetchStatuses();
setRightPane({ mode: 'detail', serverId: server.server_id });
setView({ mode: 'detail', serverId: server.server_id });
},
[loadInstalled, fetchStatuses]
);
const handleUninstalled = useCallback(
async (serverId: string) => {
log('uninstalled server_id=%s', serverId);
async (_serverId: string) => {
await loadInstalled();
await fetchStatuses();
setRightPane({ mode: 'none' });
setView({ mode: 'home' });
},
[loadInstalled, fetchStatuses]
);
const handleEnabledChange = useCallback(
async (_serverId: string, _enabled: boolean) => {
log('enabled_change server_id=%s enabled=%s', _serverId, _enabled);
await loadInstalled();
await fetchStatuses();
},
[loadInstalled, fetchStatuses]
);
// Count rejected settlements and, if any, throw a descriptive error so the
// toolbar surfaces it through its `role="alert"` region — otherwise a bulk
// action that partially (or wholly) fails looks identical to success and
// the user is left re-scanning the status dots. The status refresh still
// runs first so the dots reconcile regardless of the partial failure.
const reportBulkFailures = useCallback(
(results: PromiseSettledResult<unknown>[], total: number) => {
const failed = results.filter(r => r.status === 'rejected').length;
if (failed > 0) {
log('bulk op partial failure: %d/%d failed', failed, total);
throw new Error(
t('mcp.health.bulkPartialFailure')
.replace('{failed}', String(failed))
.replace('{total}', String(total))
);
}
},
[t]
);
const handleLoadMore = () => {
void fetchCatalog(searchQuery, catalogPage + 1, true);
};
// Bulk Retry — iterate through errored servers, collect per-server outcomes
// via `Promise.allSettled` so one bad apple doesn't abort the batch, then
// refresh statuses once at the end. The toolbar shows its own disabled state
// during the await; the next poll tick reconciles any drift. Partial/total
// failures are surfaced via `reportBulkFailures`.
const handleBulkReconnect = useCallback(
async (serverIds: string[]) => {
log('bulk reconnect ids=%o', serverIds);
const results = await Promise.allSettled(serverIds.map(id => mcpClientsApi.connect(id)));
await fetchStatuses();
reportBulkFailures(results, serverIds.length);
},
[fetchStatuses, reportBulkFailures]
);
const selectedServer =
view.mode === 'detail' ? (servers.find(s => s.server_id === view.serverId) ?? null) : null;
const selectedConnStatus =
view.mode === 'detail' ? statuses.find(s => s.server_id === view.serverId) : undefined;
// Bulk Disconnect — same shape as bulk reconnect. The toolbar gates this
// behind a confirmation dialog before we get here.
const handleBulkDisconnect = useCallback(
async (serverIds: string[]) => {
log('bulk disconnect ids=%o', serverIds);
const results = await Promise.allSettled(serverIds.map(id => mcpClientsApi.disconnect(id)));
await fetchStatuses();
reportBulkFailures(results, serverIds.length);
},
[fetchStatuses, reportBulkFailures]
);
// Filter installed servers by search
const filteredInstalled = servers.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)
);
});
const selectedServerId = rightPane.mode === 'detail' ? rightPane.serverId : null;
const selectedServer = servers.find(s => s.server_id === selectedServerId) ?? null;
const selectedConnStatus = statuses.find(s => s.server_id === selectedServerId);
// Filter out catalog servers already installed
const installedNames = new Set(servers.map(s => s.qualified_name));
const filteredCatalog = catalogServers.filter(s => !installedNames.has(s.qualified_name));
const statusMap = new Map(statuses.map(s => [s.server_id, s]));
if (loading) {
return (
@@ -208,92 +209,279 @@ const McpServersTab = () => {
);
}
// Detail view
if (view.mode === 'detail' && selectedServer) {
return (
<div className="space-y-3">
<button
type="button"
onClick={() => setView({ mode: 'home' })}
className="inline-flex items-center gap-1.5 text-xs font-medium text-stone-500 dark:text-neutral-400 hover:text-stone-700 dark:hover:text-neutral-200 transition-colors">
<svg
className="w-3.5 h-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
{t('mcp.install.back')}
</button>
<InstalledServerDetail
server={selectedServer}
connStatus={selectedConnStatus}
onUninstalled={serverId => void handleUninstalled(serverId)}
onEnabledChange={(serverId, enabled) => void handleEnabledChange(serverId, enabled)}
/>
</div>
);
}
// Install view
if (view.mode === 'install') {
return (
<div className="space-y-3">
<button
type="button"
onClick={() => setView({ mode: 'home' })}
className="inline-flex items-center gap-1.5 text-xs font-medium text-stone-500 dark:text-neutral-400 hover:text-stone-700 dark:hover:text-neutral-200 transition-colors">
<svg
className="w-3.5 h-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
{t('mcp.install.back')}
</button>
<InstallDialog
qualifiedName={view.qualifiedName}
prefillEnv={view.prefillEnv}
onSuccess={server => void handleInstallSuccess(server)}
onCancel={() => setView({ mode: 'home' })}
/>
</div>
);
}
// Home view — unified table
return (
<div className="flex flex-col gap-3 h-full min-h-0">
<div className="flex items-center gap-2">
<div
role="status"
className="flex-1 flex items-start gap-2 rounded-lg border border-amber-200 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200">
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-semibold uppercase tracking-wider bg-amber-200/70 dark:bg-amber-500/30 text-amber-900 dark:text-amber-100 shrink-0 mt-0.5">
{t('mcp.alphaBadge')}
</span>
<span className="leading-relaxed">{t('mcp.alphaBannerText')}</span>
</div>
<div className="space-y-3">
{/* Search + filter chips */}
<div className="flex items-center gap-3">
<input
type="search"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder={t('mcp.catalog.searchPlaceholder')}
aria-label={t('mcp.catalog.searchAria')}
className="flex-1 rounded-lg border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-800 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:outline-none focus:ring-2 focus:ring-primary-500/40"
/>
<button
type="button"
onClick={() => setInventoryOpen(true)}
aria-label={t('mcp.inventory.openAria')}
className="shrink-0 rounded-lg border border-stone-200 dark:border-neutral-700 px-3 py-2 text-xs font-medium text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800">
className="shrink-0 rounded-lg border border-stone-200 dark:border-neutral-700 px-3 py-2 text-xs font-medium text-stone-600 dark:text-neutral-300 hover:bg-stone-50 dark:hover:bg-neutral-800">
{t('mcp.inventory.openButton')}
</button>
</div>
<div className="flex gap-4 flex-1 min-h-0">
{/* Left pane: health toolbar + search + installed list */}
<div className="w-56 shrink-0 flex flex-col">
{loadError && (
<div className="mb-2 rounded-lg border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-3 py-2 text-xs text-coral-700 dark:text-coral-300">
{loadError}
</div>
)}
<McpConnectionHealthToolbar
statuses={statuses}
onReconnect={handleBulkReconnect}
onDisconnect={handleBulkDisconnect}
/>
{servers.length > 0 && (
<div className="mb-2">
<McpServerSearch value={searchFilter} onChange={setSearchFilter} />
</div>
)}
<InstalledServerList
servers={servers}
statuses={statuses}
selectedId={selectedServerId}
onSelect={handleSelectServer}
onBrowseCatalog={handleBrowseCatalog}
filter={searchFilter}
/>
</div>
{/* Right pane */}
<div className="flex-1 min-w-0 overflow-y-auto">
{rightPane.mode === 'none' && (
<div className="h-full flex items-center justify-center text-sm text-stone-400 dark:text-neutral-500">
{t('mcp.tab.emptyDetail')}
</div>
)}
{rightPane.mode === 'catalog' && (
<McpCatalogBrowser onSelectInstall={handleSelectInstall} />
)}
{rightPane.mode === 'install' && (
<InstallDialog
qualifiedName={rightPane.qualifiedName}
prefillEnv={rightPane.prefillEnv}
onSuccess={server => void handleInstallSuccess(server)}
onCancel={() => setRightPane({ mode: 'catalog' })}
/>
)}
{rightPane.mode === 'detail' && selectedServer && (
<InstalledServerDetail
server={selectedServer}
connStatus={selectedConnStatus}
onUninstalled={serverId => void handleUninstalled(serverId)}
onEnabledChange={(serverId, enabled) => void handleEnabledChange(serverId, enabled)}
/>
)}
</div>
{/* Filter chips */}
<div className="flex items-center gap-2">
{(['all', 'installed', 'registry'] as FilterChip[]).map(chip => (
<button
key={chip}
type="button"
onClick={() => setActiveChip(chip)}
className={`rounded-full px-3 py-1 text-xs font-medium transition-colors ${
activeChip === chip
? 'bg-primary-500 text-white'
: 'bg-stone-100 dark:bg-neutral-800 text-stone-600 dark:text-neutral-300 hover:bg-stone-200 dark:hover:bg-neutral-700'
}`}>
{chip === 'all' && t('mcp.tab.filter.all')}
{chip === 'installed' &&
t('mcp.tab.filter.installed').replace('{count}', String(filteredInstalled.length))}
{chip === 'registry' && t('mcp.tab.filter.registry')}
</button>
))}
</div>
{loadError && (
<div className="rounded-lg border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-3 py-2 text-xs text-coral-700 dark:text-coral-300">
{loadError}
</div>
)}
{/* Table */}
<div className="rounded-lg border border-stone-200 dark:border-neutral-800 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-stone-100 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-900">
<th className="text-left px-4 py-2.5 text-xs font-medium text-stone-500 dark:text-neutral-400">
{t('mcp.tab.column.name')}
</th>
<th className="text-left px-4 py-2.5 text-xs font-medium text-stone-500 dark:text-neutral-400 hidden sm:table-cell">
{t('mcp.tab.column.description')}
</th>
<th className="text-left px-4 py-2.5 text-xs font-medium text-stone-500 dark:text-neutral-400 w-24">
{t('mcp.tab.column.source')}
</th>
<th className="text-right px-4 py-2.5 text-xs font-medium text-stone-500 dark:text-neutral-400 w-28">
{t('mcp.tab.column.action')}
</th>
</tr>
</thead>
<tbody className="divide-y divide-stone-100 dark:divide-neutral-800">
{/* Installed servers */}
{(activeChip === 'all' || activeChip === 'installed') &&
filteredInstalled.map(server => {
const status: ServerStatus =
statusMap.get(server.server_id)?.status ?? 'disconnected';
return (
<tr
key={`installed-${server.server_id}`}
className="hover:bg-stone-50 dark:hover:bg-neutral-800/40 cursor-pointer transition-colors"
tabIndex={0}
role="button"
aria-label={t('mcp.tab.aria.viewDetails').replace(
'{name}',
server.display_name
)}
onClick={() => handleSelectServer(server.server_id)}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleSelectServer(server.server_id);
}
}}>
<td className="px-4 py-3">
<div className="flex items-center gap-2.5">
<span
className={`w-2 h-2 rounded-full shrink-0 ${STATUS_DOT[status]}`}
title={status}
/>
<span className="font-medium text-stone-900 dark:text-neutral-100 truncate">
{server.display_name}
</span>
</div>
</td>
<td className="px-4 py-3 hidden sm:table-cell">
<span className="text-stone-500 dark:text-neutral-400 line-clamp-1 text-xs">
{server.description ?? '—'}
</span>
</td>
<td className="px-4 py-3">
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold bg-sage-100 dark:bg-sage-500/15 text-sage-700 dark:text-sage-300 border border-sage-200 dark:border-sage-500/30">
{t('mcp.tab.badge.installed')}
</span>
</td>
<td className="px-4 py-3 text-right">
<span className="text-xs text-primary-600 dark:text-primary-400 font-medium">
{t('mcp.tab.action.manage')}
</span>
</td>
</tr>
);
})}
{/* Registry servers */}
{(activeChip === 'all' || activeChip === 'registry') &&
filteredCatalog.map(server => (
<tr
key={`catalog-${server.qualified_name}`}
className="hover:bg-stone-50 dark:hover:bg-neutral-800/40 transition-colors">
<td className="px-4 py-3">
<div className="flex items-center gap-2.5">
{server.icon_url ? (
<img
src={server.icon_url}
alt=""
className="w-5 h-5 rounded shrink-0 object-contain"
/>
) : (
<span className="w-5 h-5 rounded shrink-0 bg-primary-100 dark:bg-primary-500/20 flex items-center justify-center text-[10px]">
🔌
</span>
)}
<span className="font-medium text-stone-900 dark:text-neutral-100 truncate">
{server.display_name}
</span>
</div>
</td>
<td className="px-4 py-3 hidden sm:table-cell">
<span className="text-stone-500 dark:text-neutral-400 line-clamp-1 text-xs">
{server.description ?? '—'}
</span>
</td>
<td className="px-4 py-3">
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold bg-primary-50 dark:bg-primary-500/15 text-primary-700 dark:text-primary-300 border border-primary-200 dark:border-primary-500/30">
{t('mcp.tab.badge.registry')}
</span>
</td>
<td className="px-4 py-3 text-right">
<button
type="button"
onClick={() => handleSelectInstall(server.qualified_name)}
className="rounded-lg bg-primary-500 px-3 py-1 text-xs font-medium text-white hover:bg-primary-600 transition-colors">
{t('mcp.install.button')}
</button>
</td>
</tr>
))}
</tbody>
</table>
{/* Empty states */}
{activeChip === 'installed' && filteredInstalled.length === 0 && (
<div className="py-8 text-center text-sm text-stone-400 dark:text-neutral-500">
{t('mcp.installed.empty')}
</div>
)}
{activeChip === 'registry' && filteredCatalog.length === 0 && !catalogLoading && (
<div className="py-8 text-center text-sm text-stone-400 dark:text-neutral-500">
{searchQuery
? t('mcp.catalog.noResultsFor').replace('{query}', searchQuery)
: t('mcp.catalog.noResults')}
</div>
)}
{activeChip === 'all' &&
filteredInstalled.length === 0 &&
filteredCatalog.length === 0 &&
!catalogLoading && (
<div className="py-8 text-center text-sm text-stone-400 dark:text-neutral-500">
{searchQuery
? t('mcp.catalog.noResultsFor').replace('{query}', searchQuery)
: t('mcp.catalog.noResults')}
</div>
)}
{/* Loading / load more */}
{catalogLoading && (
<div className="py-4 text-center text-xs text-stone-400 dark:text-neutral-500">
{t('common.loading')}
</div>
)}
{!catalogLoading &&
catalogPage < catalogTotalPages &&
(activeChip === 'all' || activeChip === 'registry') && (
<div className="py-3 text-center border-t border-stone-100 dark:border-neutral-800">
<button
type="button"
onClick={handleLoadMore}
className="text-xs font-medium text-primary-600 dark:text-primary-400 hover:underline">
{t('mcp.catalog.loadMore')}
</button>
</div>
)}
</div>
{inventoryOpen && (
<McpInventoryPanel
servers={servers}
onInstallServer={(qualifiedName, prefillEnv) => {
// Hand the entry off to the existing install-dialog flow.
// The panel closes itself; here we open the dialog with the
// env keys pre-populated so the user only has to fill values.
setRightPane({ mode: 'install', qualifiedName, prefillEnv });
setInventoryOpen(false);
setView({ mode: 'install', qualifiedName, prefillEnv });
}}
onClose={() => setInventoryOpen(false)}
/>
+12 -1
View File
@@ -948,7 +948,7 @@ const messages: TranslationMap = {
'pages.settings.ai.embeddingsDesc': 'نموذج ترميز المتجهات لاسترجاع الذاكرة',
'mcp.alphaBadge': 'ألفا',
'mcp.alphaBannerText':
'دعم خادم MCP في مرحلة ألفا المبكرة. قد يتصرف سجل Smithery وتدفق التثبيت وربط الأدوات بشكل غير متوقع أو يتغير شكله بين الإصدارات.',
'دعم خادم MCP في مرحلة ألفا المبكرة. قد يتصرف سجل وتدفق التثبيت وربط الأدوات بشكل غير متوقع أو يتغير شكله بين الإصدارات.',
'mcp.toolList.noTools': 'لا توجد أدوات متاحة.',
'mcp.setup.secretDialog.title': 'MCP الإعداد - أدخل السر',
'mcp.setup.secretDialog.bodyPrefix': 'يحتاج وكيل الإعداد MCP',
@@ -1149,6 +1149,17 @@ const messages: TranslationMap = {
'ازدواجية الإسم المؤهل وجد في البيان يجب أن يظهر كل خادم مرة واحدة',
'mcp.tab.loading': 'جارٍ تحميل خوادم MCP...',
'mcp.tab.emptyDetail': 'حدد خادمًا أو تصفح الكتالوج.',
'mcp.tab.filter.all': 'الكل',
'mcp.tab.filter.installed': 'مثبت ({count})',
'mcp.tab.filter.registry': 'السجل',
'mcp.tab.column.name': 'الاسم',
'mcp.tab.column.description': 'الوصف',
'mcp.tab.column.source': 'المصدر',
'mcp.tab.column.action': 'الإجراء',
'mcp.tab.badge.installed': 'مثبت',
'mcp.tab.badge.registry': 'السجل',
'mcp.tab.action.manage': 'إدارة',
'mcp.tab.aria.viewDetails': 'عرض تفاصيل {name}',
'mcp.install.loadingDetail': 'جارٍ تحميل تفاصيل الخادم...',
'mcp.install.back': 'العودة',
'mcp.install.title': 'تثبيت {name}',
+13 -2
View File
@@ -1020,8 +1020,8 @@ const messages: TranslationMap = {
'devices.pairModal.errorPrefix': 'পেয়ারিং তৈরি করতে ব্যর্থ হয়েছে: {message}',
'devices.pairModal.errorTitle': 'কিছু ভুল হয়েছে',
'devices.pairModal.copyUrl': 'অনুলিপি',
'mcp.catalog.searchAria': 'Smithery ক্যাটালগ অনুসন্ধান',
'mcp.catalog.searchPlaceholder': 'Smithery ক্যাটালগ অনুসন্ধান করুন...',
'mcp.catalog.searchAria': 'MCP সার্ভার ক্যাটালগ অনুসন্ধান',
'mcp.catalog.searchPlaceholder': 'MCP সার্ভার অনুসন্ধান করুন...',
'mcp.catalog.loadFailed': 'ক্যাটালগ করতে ব্যর্থ হয়েছে',
'mcp.catalog.noResults': 'কোনো সার্ভার পাওয়া যায়নি।',
'mcp.catalog.noResultsFor': '"{query}" এর জন্য কোনো সার্ভার পাওয়া যায়নি।',
@@ -1168,6 +1168,17 @@ const messages: TranslationMap = {
'অফ-লাইন অবস্থায় উপস্থিত সর্বোচ্চ সংখ্যা: ( n) প্রতিটি সার্ভারের মধ্যে সবচেয়ে বেশি উপস্থিত থাকা আবশ্যক ।',
'mcp.tab.loading': 'MCP সার্ভার লোড হচ্ছে...',
'mcp.tab.emptyDetail': 'একটি সার্ভার বা সারি নির্বাচন করুন।',
'mcp.tab.filter.all': 'সব',
'mcp.tab.filter.installed': 'ইনস্টল করা ({count})',
'mcp.tab.filter.registry': 'রেজিস্ট্রি',
'mcp.tab.column.name': 'নাম',
'mcp.tab.column.description': 'বিবরণ',
'mcp.tab.column.source': 'উৎস',
'mcp.tab.column.action': 'ক্রিয়া',
'mcp.tab.badge.installed': 'ইনস্টল করা',
'mcp.tab.badge.registry': 'রেজিস্ট্রি',
'mcp.tab.action.manage': 'পরিচালনা',
'mcp.tab.aria.viewDetails': '{name} এর বিবরণ দেখুন',
'mcp.install.loadingDetail': 'সার্ভারের বিবরণ লোড হচ্ছে...',
'mcp.install.back': 'ফিরে যান',
'mcp.install.title': '{name} ইনস্টল করুন',
+14 -3
View File
@@ -991,7 +991,7 @@ const messages: TranslationMap = {
'pages.settings.ai.embeddingsDesc': 'Vektorkodierungsmodell für den Speicherabruf',
'mcp.alphaBadge': 'Alpha',
'mcp.alphaBannerText':
'Die MCP-Serverunterstützung befindet sich in der frühen Alpha-Phase. Die Smithery-Registrierung, der Installationsablauf und die Werkzeugverkabelung können sich zwischen den Versionen falsch verhalten oder ihre Form ändern.',
'Die MCP-Serverunterstützung befindet sich in der frühen Alpha-Phase. Die Registrierung, der Installationsablauf und die Werkzeugverkabelung können sich zwischen den Versionen falsch verhalten oder ihre Form ändern.',
'mcp.toolList.noTools': 'Keine Tools verfügbar.',
'mcp.setup.secretDialog.title': 'MCP-Setup Geben Sie das Geheimnis ein',
'mcp.setup.secretDialog.bodyPrefix': 'Der MCP-Setup-Agent benötigt',
@@ -1053,8 +1053,8 @@ const messages: TranslationMap = {
'devices.pairModal.errorPrefix': 'Kopplung konnte nicht erstellt werden: {message}',
'devices.pairModal.errorTitle': 'Etwas ist schiefgelaufen',
'devices.pairModal.copyUrl': 'Kopieren',
'mcp.catalog.searchAria': 'Smithery-Katalog durchsuchen',
'mcp.catalog.searchPlaceholder': 'Smithery-Katalog durchsuchen...',
'mcp.catalog.searchAria': 'MCP-Server-Katalog durchsuchen',
'mcp.catalog.searchPlaceholder': 'MCP-Server-Katalog durchsuchen...',
'mcp.catalog.loadFailed': 'Katalog konnte nicht geladen werden',
'mcp.catalog.noResults': 'Keine Server gefunden.',
'mcp.catalog.noResultsFor': 'Keine Server für „{query}“ gefunden.',
@@ -1206,6 +1206,17 @@ const messages: TranslationMap = {
'Doppelter qualifizierter Name im Manifest gefunden. Jeder Server darf höchstens einmal vorkommen.',
'mcp.tab.loading': 'MCP-Server werden geladen...',
'mcp.tab.emptyDetail': 'Wählen Sie einen Server aus oder durchsuchen Sie den Katalog.',
'mcp.tab.filter.all': 'Alle',
'mcp.tab.filter.installed': 'Installiert ({count})',
'mcp.tab.filter.registry': 'Registrierung',
'mcp.tab.column.name': 'Name',
'mcp.tab.column.description': 'Beschreibung',
'mcp.tab.column.source': 'Quelle',
'mcp.tab.column.action': 'Aktion',
'mcp.tab.badge.installed': 'Installiert',
'mcp.tab.badge.registry': 'Registrierung',
'mcp.tab.action.manage': 'Verwalten',
'mcp.tab.aria.viewDetails': 'Details für {name} anzeigen',
'mcp.install.loadingDetail': 'Serverdetails werden geladen...',
'mcp.install.back': 'Zurück',
'mcp.install.title': 'Installieren Sie {name}',
+14 -3
View File
@@ -1291,7 +1291,7 @@ const en: TranslationMap = {
'mcp.alphaBadge': 'Alpha',
'mcp.alphaBannerText':
'MCP server support is in early alpha. The Smithery registry, install flow, and tool wiring may misbehave or change shape between releases.',
'MCP server support is in early alpha. The registry, install flow, and tool wiring may change between releases.',
'mcp.toolList.noTools': 'No tools available.',
'mcp.setup.secretDialog.title': 'MCP Setup — Enter Secret',
'mcp.setup.secretDialog.bodyPrefix': 'The MCP setup agent needs',
@@ -1350,8 +1350,8 @@ const en: TranslationMap = {
'devices.pairModal.errorPrefix': 'Failed to create pairing: {message}',
'devices.pairModal.errorTitle': 'Something went wrong',
'devices.pairModal.copyUrl': 'Copy',
'mcp.catalog.searchAria': 'Search Smithery catalog',
'mcp.catalog.searchPlaceholder': 'Search Smithery catalog...',
'mcp.catalog.searchAria': 'Search MCP server catalog',
'mcp.catalog.searchPlaceholder': 'Search MCP servers...',
'mcp.catalog.loadFailed': 'Failed to load catalog',
'mcp.catalog.noResults': 'No servers found.',
'mcp.catalog.noResultsFor': 'No servers found for "{query}".',
@@ -1496,6 +1496,17 @@ const en: TranslationMap = {
'Duplicate qualified_name found in manifest. Each server must appear at most once.',
'mcp.tab.loading': 'Loading MCP servers...',
'mcp.tab.emptyDetail': 'Select a server or browse the catalog.',
'mcp.tab.filter.all': 'All',
'mcp.tab.filter.installed': 'Installed ({count})',
'mcp.tab.filter.registry': 'Registry',
'mcp.tab.column.name': 'Name',
'mcp.tab.column.description': 'Description',
'mcp.tab.column.source': 'Source',
'mcp.tab.column.action': 'Action',
'mcp.tab.badge.installed': 'Installed',
'mcp.tab.badge.registry': 'Registry',
'mcp.tab.action.manage': 'Manage',
'mcp.tab.aria.viewDetails': 'View details for {name}',
'mcp.install.loadingDetail': 'Loading server details...',
'mcp.install.back': 'Go back',
'mcp.install.title': 'Install {name}',
+12 -1
View File
@@ -989,7 +989,7 @@ const messages: TranslationMap = {
'Modelo de codificación vectorial para recuperación de memoria',
'mcp.alphaBadge': 'Alfa',
'mcp.alphaBannerText':
'La compatibilidad con el servidor MCP se encuentra en las primeras etapas alfa. El registro de Smithery, el flujo de instalación y el cableado de herramientas pueden comportarse mal o cambiar de forma entre versiones.',
'La compatibilidad con el servidor MCP se encuentra en las primeras etapas alfa. El registro, el flujo de instalación y el cableado de herramientas pueden comportarse mal o cambiar de forma entre versiones.',
'mcp.toolList.noTools': 'No hay herramientas disponibles.',
'mcp.setup.secretDialog.title': 'Configuración MCP: ingresar secreto',
'mcp.setup.secretDialog.bodyPrefix': 'El agente de configuración MCP necesita',
@@ -1202,6 +1202,17 @@ const messages: TranslationMap = {
'Nombre_calificado duplicado encontrado en el manifiesto. Cada servidor debe aparecer como máximo una vez.',
'mcp.tab.loading': 'Cargando servidores MCP...',
'mcp.tab.emptyDetail': 'Seleccione un servidor o explore el catálogo.',
'mcp.tab.filter.all': 'Todos',
'mcp.tab.filter.installed': 'Instalados ({count})',
'mcp.tab.filter.registry': 'Registro',
'mcp.tab.column.name': 'Nombre',
'mcp.tab.column.description': 'Descripción',
'mcp.tab.column.source': 'Origen',
'mcp.tab.column.action': 'Acción',
'mcp.tab.badge.installed': 'Instalado',
'mcp.tab.badge.registry': 'Registro',
'mcp.tab.action.manage': 'Gestionar',
'mcp.tab.aria.viewDetails': 'Ver detalles de {name}',
'mcp.install.loadingDetail': 'Cargando detalles del servidor...',
'mcp.install.back': 'volver',
'mcp.install.title': 'Instalar {name}',
+14 -3
View File
@@ -991,7 +991,7 @@ const messages: TranslationMap = {
"Modèle d'encodage vectoriel pour la récupération de la mémoire",
'mcp.alphaBadge': 'Alpha',
'mcp.alphaBannerText':
"Le support du serveur MCP est en phase alpha précoce. Le registre Smithery, le flux d'installation et le câblage des outils peuvent mal fonctionner ou changer de forme entre les versions.",
"Le support du serveur MCP est en phase alpha précoce. Le registre, le flux d'installation et le câblage des outils peuvent mal fonctionner ou changer de forme entre les versions.",
'mcp.toolList.noTools': 'Aucun outil disponible.',
'mcp.setup.secretDialog.title': 'Installation MCP — Entrez le secret',
'mcp.setup.secretDialog.bodyPrefix': "L'agent de configuration MCP a besoin",
@@ -1053,8 +1053,8 @@ const messages: TranslationMap = {
'devices.pairModal.errorPrefix': 'Échec de la création du couplage : {message}',
'devices.pairModal.errorTitle': 'Un problème est survenu',
'devices.pairModal.copyUrl': 'Copier',
'mcp.catalog.searchAria': 'Rechercher dans le catalogue Smithery',
'mcp.catalog.searchPlaceholder': 'Rechercher dans le catalogue Smithery...',
'mcp.catalog.searchAria': 'Rechercher des serveurs MCP',
'mcp.catalog.searchPlaceholder': 'Rechercher des serveurs MCP...',
'mcp.catalog.loadFailed': 'Échec du chargement du catalogue',
'mcp.catalog.noResults': 'Aucun serveur trouvé.',
'mcp.catalog.noResultsFor': 'Aucun serveur trouvé pour "{query}".',
@@ -1206,6 +1206,17 @@ const messages: TranslationMap = {
'Nom_qualifié dupliqué trouvé dans le manifeste. Chaque serveur doit apparaître au maximum une fois.',
'mcp.tab.loading': 'Chargement des serveurs MCP...',
'mcp.tab.emptyDetail': 'Sélectionnez un serveur ou parcourez le catalogue.',
'mcp.tab.filter.all': 'Tout',
'mcp.tab.filter.installed': 'Installés ({count})',
'mcp.tab.filter.registry': 'Registre',
'mcp.tab.column.name': 'Nom',
'mcp.tab.column.description': 'Description',
'mcp.tab.column.source': 'Source',
'mcp.tab.column.action': 'Action',
'mcp.tab.badge.installed': 'Installé',
'mcp.tab.badge.registry': 'Registre',
'mcp.tab.action.manage': 'Gérer',
'mcp.tab.aria.viewDetails': 'Voir les détails de {name}',
'mcp.install.loadingDetail': 'Chargement des détails du serveur...',
'mcp.install.back': 'Retourner',
'mcp.install.title': 'Installer {name}',
+11
View File
@@ -1169,6 +1169,17 @@ const messages: TranslationMap = {
'डुप्लिकेट योग्य नाम प्रकट होने में पाया गया। प्रत्येक सर्वर को एक बार में प्रदर्शित होना चाहिए।',
'mcp.tab.loading': 'MCP सर्वर लोड हो रहा है...',
'mcp.tab.emptyDetail': 'एक सर्वर चुनें या कैटलॉग ब्राउज़ करें।',
'mcp.tab.filter.all': 'सभी',
'mcp.tab.filter.installed': 'इंस्टॉल किए गए ({count})',
'mcp.tab.filter.registry': 'रजिस्ट्री',
'mcp.tab.column.name': 'नाम',
'mcp.tab.column.description': 'विवरण',
'mcp.tab.column.source': 'स्रोत',
'mcp.tab.column.action': 'क्रिया',
'mcp.tab.badge.installed': 'इंस्टॉल किया गया',
'mcp.tab.badge.registry': 'रजिस्ट्री',
'mcp.tab.action.manage': 'प्रबंधित करें',
'mcp.tab.aria.viewDetails': '{name} के विवरण देखें',
'mcp.install.loadingDetail': 'सर्वर विवरण लोड हो रहा है...',
'mcp.install.back': 'वापस जाओ',
'mcp.install.title': '{name} स्थापित करें',
+14 -3
View File
@@ -968,7 +968,7 @@ const messages: TranslationMap = {
'pages.settings.ai.embeddingsDesc': 'Model encoding vektor untuk pengambilan memori',
'mcp.alphaBadge': 'Alfa',
'mcp.alphaBannerText':
'Dukungan server MCP masih dalam tahap alpha awal. Registry Smithery, alur instalasi, dan koneksi alat mungkin berperilaku tidak terduga atau berubah antara rilis.',
'Dukungan server MCP masih dalam tahap alpha awal. Registry, alur instalasi, dan koneksi alat mungkin berperilaku tidak terduga atau berubah antara rilis.',
'mcp.toolList.noTools': 'Tidak ada alat yang tersedia.',
'mcp.setup.secretDialog.title': 'MCP Penyiapan — Masukkan Rahasia',
'mcp.setup.secretDialog.bodyPrefix': 'Agen penyiapan MCP memerlukan',
@@ -1029,8 +1029,8 @@ const messages: TranslationMap = {
'devices.pairModal.errorPrefix': 'Gagal membuat penyandingan: {message}',
'devices.pairModal.errorTitle': 'Ada yang tidak beres',
'devices.pairModal.copyUrl': 'Salin',
'mcp.catalog.searchAria': 'Cari katalog Smithery',
'mcp.catalog.searchPlaceholder': 'Cari katalog Smithery...',
'mcp.catalog.searchAria': 'Cari katalog server MCP',
'mcp.catalog.searchPlaceholder': 'Cari katalog server MCP...',
'mcp.catalog.loadFailed': 'Gagal memuat katalog',
'mcp.catalog.noResults': 'Tidak ada server yang ditemukan.',
'mcp.catalog.noResultsFor': 'Tidak ditemukan server untuk "{query}".',
@@ -1177,6 +1177,17 @@ const messages: TranslationMap = {
'Duplikasi _ nama memenuhi syarat ditemukan dalam daftar muatan. Setiap server harus muncul sekali.',
'mcp.tab.loading': 'Memuat MCP server...',
'mcp.tab.emptyDetail': 'Pilih server atau telusuri katalog.',
'mcp.tab.filter.all': 'Semua',
'mcp.tab.filter.installed': 'Terpasang ({count})',
'mcp.tab.filter.registry': 'Registri',
'mcp.tab.column.name': 'Nama',
'mcp.tab.column.description': 'Deskripsi',
'mcp.tab.column.source': 'Sumber',
'mcp.tab.column.action': 'Aksi',
'mcp.tab.badge.installed': 'Terpasang',
'mcp.tab.badge.registry': 'Registri',
'mcp.tab.action.manage': 'Kelola',
'mcp.tab.aria.viewDetails': 'Lihat detail untuk {name}',
'mcp.install.loadingDetail': 'Memuat detail server...',
'mcp.install.back': 'Kembali',
'mcp.install.title': 'Instal {name}',
+12 -1
View File
@@ -984,7 +984,7 @@ const messages: TranslationMap = {
'Modello di codifica vettoriale per il recupero della memoria',
'mcp.alphaBadge': 'Alfa',
'mcp.alphaBannerText':
'Il supporto del server MCP è in fase alpha iniziale. Il registro Smithery, il flusso di installazione e il collegamento degli strumenti potrebbero comportarsi in modo anomalo o cambiare forma tra le versioni.',
'Il supporto del server MCP è in fase alpha iniziale. Il registro, il flusso di installazione e il collegamento degli strumenti potrebbero comportarsi in modo anomalo o cambiare forma tra le versioni.',
'mcp.toolList.noTools': 'Nessuno strumento disponibile.',
'mcp.setup.secretDialog.title': 'MCP Configurazione: inserisci il segreto',
'mcp.setup.secretDialog.bodyPrefix': "L'agente di configurazione MCP è necessario",
@@ -1198,6 +1198,17 @@ const messages: TranslationMap = {
'Nome_qualificato duplicato trovato nel manifesto. Ogni server deve apparire al massimo una volta.',
'mcp.tab.loading': 'Caricamento MCP server...',
'mcp.tab.emptyDetail': 'Selezionare un server o sfogliare il catalogo.',
'mcp.tab.filter.all': 'Tutti',
'mcp.tab.filter.installed': 'Installati ({count})',
'mcp.tab.filter.registry': 'Registro',
'mcp.tab.column.name': 'Nome',
'mcp.tab.column.description': 'Descrizione',
'mcp.tab.column.source': 'Origine',
'mcp.tab.column.action': 'Azione',
'mcp.tab.badge.installed': 'Installato',
'mcp.tab.badge.registry': 'Registro',
'mcp.tab.action.manage': 'Gestisci',
'mcp.tab.aria.viewDetails': 'Visualizza dettagli per {name}',
'mcp.install.loadingDetail': 'Caricamento dettagli server...',
'mcp.install.back': 'Torna indietro',
'mcp.install.title': 'Installa {name}',
+14 -3
View File
@@ -959,7 +959,7 @@ const messages: TranslationMap = {
'pages.settings.ai.embeddingsDesc': '메모리 검색을 위한 벡터 인코딩 모델',
'mcp.alphaBadge': '알파',
'mcp.alphaBannerText':
'MCP 서버 지원은 초기 알파 단계입니다. Smithery 레지스트리, 설치 흐름 및 도구 연결은 릴리스 사이에 오작동하거나 형태가 바뀔 수 있습니다.',
'MCP 서버 지원은 초기 알파 단계입니다. 레지스트리, 설치 흐름 및 도구 연결은 릴리스 사이에 오작동하거나 형태가 바뀔 수 있습니다.',
'mcp.toolList.noTools': '사용 가능한 도구가 없습니다.',
'mcp.setup.secretDialog.title': 'MCP 설정 - 비밀번호 입력',
'mcp.setup.secretDialog.bodyPrefix': 'MCP 설정 에이전트에는 다음이 필요합니다.',
@@ -1019,8 +1019,8 @@ const messages: TranslationMap = {
'devices.pairModal.errorPrefix': '페어링 생성 실패: {message}',
'devices.pairModal.errorTitle': '문제가 발생했습니다.',
'devices.pairModal.copyUrl': '복사',
'mcp.catalog.searchAria': 'Smithery 카탈로그 검색',
'mcp.catalog.searchPlaceholder': 'Smithery 카탈로그 검색...',
'mcp.catalog.searchAria': 'MCP 서버 카탈로그 검색',
'mcp.catalog.searchPlaceholder': 'MCP 서버 카탈로그 검색...',
'mcp.catalog.loadFailed': '카탈로그를 로드하지 못했습니다.',
'mcp.catalog.noResults': '서버를 찾을 수 없습니다.',
'mcp.catalog.noResultsFor': '"{query}"에 대한 서버를 찾을 수 없습니다.',
@@ -1164,6 +1164,17 @@ const messages: TranslationMap = {
'매니페스트에서 중복된 qualified_name을 찾았습니다. 각 서버는 최대 한 번만 나타나야 합니다.',
'mcp.tab.loading': 'MCP 서버 로드 중...',
'mcp.tab.emptyDetail': '서버를 선택하거나 카탈로그를 찾아보세요.',
'mcp.tab.filter.all': '전체',
'mcp.tab.filter.installed': '설치됨 ({count})',
'mcp.tab.filter.registry': '레지스트리',
'mcp.tab.column.name': '이름',
'mcp.tab.column.description': '설명',
'mcp.tab.column.source': '출처',
'mcp.tab.column.action': '작업',
'mcp.tab.badge.installed': '설치됨',
'mcp.tab.badge.registry': '레지스트리',
'mcp.tab.action.manage': '관리',
'mcp.tab.aria.viewDetails': '{name} 세부정보 보기',
'mcp.install.loadingDetail': '서버 세부정보 로드 중...',
'mcp.install.back': '뒤로 가기',
'mcp.install.title': '{name} 설치',
+14 -3
View File
@@ -978,7 +978,7 @@ const messages: TranslationMap = {
'pages.settings.ai.embeddingsDesc': 'Model kodowania wektorowego do wyszukiwania w pamięci',
'mcp.alphaBadge': 'Alfa',
'mcp.alphaBannerText':
'Obsługa serwerów MCP jest we wczesnej fazie alpha. Rejestr Smithery, instalacja i podpinanie narzędzi mogą się zmieniać między wydaniami.',
'Obsługa serwerów MCP jest we wczesnej fazie alpha. Rejestr, instalacja i podpinanie narzędzi mogą się zmieniać między wydaniami.',
'mcp.toolList.noTools': 'Brak dostępnych narzędzi.',
'mcp.setup.secretDialog.title': 'Konfiguracja MCP — wpisz sekret',
'mcp.setup.secretDialog.bodyPrefix': 'Asystent konfiguracji MCP potrzebuje',
@@ -1038,8 +1038,8 @@ const messages: TranslationMap = {
'devices.pairModal.errorPrefix': 'Nie udało się utworzyć parowania: {message}',
'devices.pairModal.errorTitle': 'Coś poszło nie tak',
'devices.pairModal.copyUrl': 'Skopiuj',
'mcp.catalog.searchAria': 'Szukaj w katalogu Smithery',
'mcp.catalog.searchPlaceholder': 'Szukaj w katalogu Smithery...',
'mcp.catalog.searchAria': 'Szukaj serwerów MCP',
'mcp.catalog.searchPlaceholder': 'Szukaj serwerów MCP...',
'mcp.catalog.loadFailed': 'Nie udało się załadować katalogu',
'mcp.catalog.noResults': 'Nie znaleziono serwerów.',
'mcp.catalog.noResultsFor': 'Nie znaleziono serwerów dla „{query}”.',
@@ -1189,6 +1189,17 @@ const messages: TranslationMap = {
'W manifeście znaleziono zduplikowaną wartość qualified_name. Każdy serwer może wystąpić najwyżej raz.',
'mcp.tab.loading': 'Ładowanie serwerów MCP...',
'mcp.tab.emptyDetail': 'Wybierz serwer lub przeglądaj katalog.',
'mcp.tab.filter.all': 'Wszystkie',
'mcp.tab.filter.installed': 'Zainstalowane ({count})',
'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.action': 'Akcja',
'mcp.tab.badge.installed': 'Zainstalowane',
'mcp.tab.badge.registry': 'Rejestr',
'mcp.tab.action.manage': 'Zarządzaj',
'mcp.tab.aria.viewDetails': 'Zobacz szczegóły dla {name}',
'mcp.install.loadingDetail': 'Ładowanie szczegółów serwera...',
'mcp.install.back': 'Wstecz',
'mcp.install.title': 'Zainstaluj {name}',
+14 -3
View File
@@ -989,7 +989,7 @@ const messages: TranslationMap = {
'pages.settings.ai.embeddingsDesc': 'Modelo de codificação vetorial para recuperação de memória',
'mcp.alphaBadge': 'Alfa',
'mcp.alphaBannerText':
'O suporte do servidor MCP está em alfa inicial. O registro Smithery, o fluxo de instalação e a conexão das ferramentas podem se comportar de maneira inadequada ou mudar de forma entre as versões.',
'O suporte do servidor MCP está em alfa inicial. O registro, o fluxo de instalação e a conexão das ferramentas podem se comportar de maneira inadequada ou mudar de forma entre as versões.',
'mcp.toolList.noTools': 'Nenhuma ferramenta disponível.',
'mcp.setup.secretDialog.title': 'MCP Configuração - Insira o segredo',
'mcp.setup.secretDialog.bodyPrefix': 'O agente de configuração MCP precisa de',
@@ -1050,8 +1050,8 @@ const messages: TranslationMap = {
'devices.pairModal.errorPrefix': 'Falha ao criar emparelhamento: {message}',
'devices.pairModal.errorTitle': 'Algo deu errado',
'devices.pairModal.copyUrl': 'Copiar',
'mcp.catalog.searchAria': 'Pesquise no catálogo da Smithery',
'mcp.catalog.searchPlaceholder': 'Pesquisar catálogo da Smithery...',
'mcp.catalog.searchAria': 'Pesquisar catálogo de servidores MCP',
'mcp.catalog.searchPlaceholder': 'Pesquisar servidores MCP...',
'mcp.catalog.loadFailed': 'Falha ao carregar o catálogo',
'mcp.catalog.noResults': 'Nenhum servidor encontrado.',
'mcp.catalog.noResultsFor': 'Nenhum servidor encontrado para "{query}".',
@@ -1203,6 +1203,17 @@ const messages: TranslationMap = {
'Nome_qualificado duplicado encontrado no manifesto. Cada servidor deve aparecer no máximo uma vez.',
'mcp.tab.loading': 'Carregando servidores MCP...',
'mcp.tab.emptyDetail': 'Selecione um servidor ou navegue no catálogo.',
'mcp.tab.filter.all': 'Todos',
'mcp.tab.filter.installed': 'Instalados ({count})',
'mcp.tab.filter.registry': 'Registro',
'mcp.tab.column.name': 'Nome',
'mcp.tab.column.description': 'Descrição',
'mcp.tab.column.source': 'Origem',
'mcp.tab.column.action': 'Ação',
'mcp.tab.badge.installed': 'Instalado',
'mcp.tab.badge.registry': 'Registro',
'mcp.tab.action.manage': 'Gerenciar',
'mcp.tab.aria.viewDetails': 'Ver detalhes de {name}',
'mcp.install.loadingDetail': 'Carregando detalhes do servidor...',
'mcp.install.back': 'Voltar',
'mcp.install.title': 'Instalar {name}',
+12 -1
View File
@@ -974,7 +974,7 @@ const messages: TranslationMap = {
'pages.settings.ai.embeddingsDesc': 'Модель векторного кодирования для извлечения из памяти',
'mcp.alphaBadge': 'Альфа',
'mcp.alphaBannerText':
'Поддержка сервера MCP находится в ранней альфа-версии. Реестр Smithery, процесс установки и подключение инструментов могут работать неправильно или изменять форму между выпусками.',
'Поддержка сервера MCP находится в ранней альфа-версии. Реестр, процесс установки и подключение инструментов могут работать неправильно или изменять форму между выпусками.',
'mcp.toolList.noTools': 'Инструменты недоступны.',
'mcp.setup.secretDialog.title': 'MCP Настройка — введите секретный код',
'mcp.setup.secretDialog.bodyPrefix': 'Агенту установки MCP требуется',
@@ -1184,6 +1184,17 @@ const messages: TranslationMap = {
'В манифесте найдено повторяющееся квалифицированное_имя. Каждый сервер должен появиться не более одного раза.',
'mcp.tab.loading': 'Загрузка серверов MCP...',
'mcp.tab.emptyDetail': 'Выберите сервер или просмотрите каталог.',
'mcp.tab.filter.all': 'Все',
'mcp.tab.filter.installed': 'Установлено ({count})',
'mcp.tab.filter.registry': 'Реестр',
'mcp.tab.column.name': 'Название',
'mcp.tab.column.description': 'Описание',
'mcp.tab.column.source': 'Источник',
'mcp.tab.column.action': 'Действие',
'mcp.tab.badge.installed': 'Установлено',
'mcp.tab.badge.registry': 'Реестр',
'mcp.tab.action.manage': 'Управление',
'mcp.tab.aria.viewDetails': 'Просмотреть детали {name}',
'mcp.install.loadingDetail': 'Загрузка сведений о сервере...',
'mcp.install.back': 'Вернитесь назад',
'mcp.install.title': 'Установите {name}',
+12 -1
View File
@@ -913,7 +913,7 @@ const messages: TranslationMap = {
'pages.settings.ai.embeddingsDesc': '用于记忆检索的向量编码模型',
'mcp.alphaBadge': '阿尔法',
'mcp.alphaBannerText':
'MCP 服务器支持仍处于早期 alpha 阶段。Smithery 注册表、安装流程和工具接线在不同版本间可能异常或发生变化。',
'MCP 服务器支持仍处于早期 alpha 阶段。注册表、安装流程和工具接线在不同版本间可能异常或发生变化。',
'mcp.toolList.noTools': '没有可用的工具。',
'mcp.setup.secretDialog.title': 'MCP 设置 — 输入密码',
'mcp.setup.secretDialog.bodyPrefix': 'MCP 设置代理需要',
@@ -1111,6 +1111,17 @@ const messages: TranslationMap = {
'Manifest 中发现重复的 qualified_name。每个服务器最多只能出现一次。',
'mcp.tab.loading': '正在加载 MCP 服务器...',
'mcp.tab.emptyDetail': '选择服务器或浏览目录。',
'mcp.tab.filter.all': '全部',
'mcp.tab.filter.installed': '已安装 ({count})',
'mcp.tab.filter.registry': '注册表',
'mcp.tab.column.name': '名称',
'mcp.tab.column.description': '描述',
'mcp.tab.column.source': '来源',
'mcp.tab.column.action': '操作',
'mcp.tab.badge.installed': '已安装',
'mcp.tab.badge.registry': '注册表',
'mcp.tab.action.manage': '管理',
'mcp.tab.aria.viewDetails': '查看 {name} 的详情',
'mcp.install.loadingDetail': '正在加载服务器详细信息...',
'mcp.install.back': '返回',
'mcp.install.title': '安装 {name}',
+2 -24
View File
@@ -946,30 +946,8 @@ export default function Skills() {
{activeTab === 'skills' && <SkillsExplorerTab onToast={addToast} />}
{activeTab === 'mcp' && (
<div className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 shadow-soft animate-fade-up">
<div className="pb-3">
<h2 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('channels.mcp.title')}
</h2>
<p className="mt-0.5 text-[11px] leading-relaxed text-stone-500 dark:text-neutral-400">
{t('channels.mcp.description')}
</p>
</div>
{IS_DEV ? (
<div className="h-[72vh] min-h-[480px]">
<McpServersTab />
</div>
) : (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="text-3xl mb-3">🔌</div>
<p className="text-sm font-medium text-stone-700 dark:text-neutral-300">
{t('misc.comingSoon')}
</p>
<p className="mt-1 text-xs text-stone-500 dark:text-neutral-400">
{t('channels.mcp.description')}
</p>
</div>
)}
<div className="animate-fade-up">
<McpServersTab />
</div>
)}
</>
@@ -50,16 +50,46 @@ vi.mock('../../services/api/mcpClientsApi', () => ({
}));
describe('Skills page — MCP tab', () => {
it('renders the live MCP servers tab (not a coming-soon placeholder)', async () => {
it('renders the live MCP servers tab with unified table view (not a coming-soon placeholder)', async () => {
renderWithProviders(<Skills />, { initialEntries: ['/skills'] });
fireEvent.click(screen.getByRole('tab', { name: 'MCP Servers' }));
// The new tab shows filter chips (All / Installed / Registry) and a search input
await waitFor(() => {
expect(screen.getByRole('button', { name: 'All' })).toBeInTheDocument();
});
expect(screen.getByRole('button', { name: /Installed/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Registry' })).toBeInTheDocument();
});
it('shows the table header columns on the MCP tab', async () => {
renderWithProviders(<Skills />, { initialEntries: ['/skills'] });
fireEvent.click(screen.getByRole('tab', { name: 'MCP Servers' }));
// Wait for initial load to complete
await waitFor(() => {
expect(screen.queryByText('Loading MCP servers...')).not.toBeInTheDocument();
});
expect(screen.getByText('Name')).toBeInTheDocument();
expect(screen.getByText('Source')).toBeInTheDocument();
expect(screen.getByText('Action')).toBeInTheDocument();
});
it('shows empty-installed state when Installed chip is clicked', async () => {
renderWithProviders(<Skills />, { initialEntries: ['/skills'] });
fireEvent.click(screen.getByRole('tab', { name: 'MCP Servers' }));
await waitFor(() => {
expect(
screen.getByText('No MCP servers installed yet.') ||
screen.getByText('Loading MCP servers...')
).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Installed/i })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: /Installed/i }));
await waitFor(() => {
expect(screen.getByText('No MCP servers installed yet.')).toBeInTheDocument();
});
});
});
+1 -1
View File
@@ -56,7 +56,7 @@ interface InstallAndConnectResult {
}
export const mcpSetupApi = {
/** Search all enabled registries (Smithery + official). */
/** Search all enabled registries (official modelcontextprotocol.io primary, Smithery fallback). */
search: async (params: {
query?: string;
page?: number;
@@ -0,0 +1,166 @@
// @ts-nocheck
/**
* MCP Setup Secret Dialog — desktop E2E (Appium / WDIO).
*
* Verifies that when the core publishes a `McpSetupSecretRequested` event,
* the SecretPromptDialog appears in the native desktop WebView, collects
* the user's input via a masked password field, and submits it only
* through the `mcp_setup_submit_secret` RPC — never exposing the raw
* value to any agent-facing channel.
*
* This is the Appium counterpart to the Playwright spec at
* `test/playwright/specs/mcp-setup-secret-flow.spec.ts`. Both cover the
* same functional contract; this one exercises it in the actual Tauri
* desktop shell via CEF/WebView.
*/
import { waitForApp } from '../helpers/app-helpers';
import { resetApp } from '../helpers/reset-app';
import { startMockServer, stopMockServer } from '../mock-server';
const USER_ID = 'e2e-mcp-secret-dialog';
describe('MCP Setup — Secret Dialog', () => {
before(async function () {
this.timeout(90_000);
await startMockServer();
await waitForApp();
await resetApp(USER_ID);
});
after(async () => {
await stopMockServer();
});
it('dialog renders on McpSetupSecretRequested event', async () => {
// Dispatch the event that the socket service forwards from core
await browser.execute(() => {
window.dispatchEvent(
new CustomEvent('openhuman:mcp-setup-secret-requested', {
detail: {
refId: 'secret://e2e_test_ref1',
keyName: 'TEST_API_KEY',
prompt: 'Enter your test API key to verify the dialog.',
},
})
);
});
// Wait for the dialog to appear
const dialog = await $('[role="dialog"]');
await dialog.waitForDisplayed({ timeout: 5_000 });
// Verify key name and prompt text are shown
const keyNameEl = await dialog.$('code');
const keyNameText = await keyNameEl.getText();
expect(keyNameText).toContain('TEST_API_KEY');
const dialogText = await dialog.getText();
expect(dialogText).toContain('Enter your test API key to verify the dialog.');
});
it('input is masked (type=password) by default', async () => {
const input = await $('#mcp-setup-secret-input');
const type = await input.getAttribute('type');
expect(type).toBe('password');
});
it('show/hide toggle changes input type', async () => {
const input = await $('#mcp-setup-secret-input');
const dialog = await $('[role="dialog"]');
// Find and click the "Show" button
const showBtn = await dialog.$('button*=Show');
await showBtn.click();
expect(await input.getAttribute('type')).toBe('text');
// Toggle back
const hideBtn = await dialog.$('button*=Hide');
await hideBtn.click();
expect(await input.getAttribute('type')).toBe('password');
});
it('submit button is disabled when input is empty', async () => {
const submitBtn = await $('[role="dialog"] button[type="submit"]');
expect(await submitBtn.isEnabled()).toBe(false);
});
it('cancel dismisses the dialog without submitting', async () => {
// Click cancel
const dialog = await $('[role="dialog"]');
const cancelBtn = await dialog.$('button*=Cancel');
await cancelBtn.click();
// Dialog should not be displayed
await dialog.waitForDisplayed({ timeout: 3_000, reverse: true });
});
it('submit sends secret only to mcp_setup_submit_secret RPC', async () => {
// Re-trigger a new dialog
await browser.execute(() => {
window.dispatchEvent(
new CustomEvent('openhuman:mcp-setup-secret-requested', {
detail: {
refId: 'secret://e2e_test_ref2',
keyName: 'SECOND_KEY',
prompt: 'Second key prompt.',
},
})
);
});
const dialog = await $('[role="dialog"]');
await dialog.waitForDisplayed({ timeout: 5_000 });
// Intercept outgoing RPC calls by patching the fetch in the page
await browser.execute(() => {
(window as any).__e2eRpcLog = [];
const origFetch = window.fetch;
window.fetch = async function (...args: any[]) {
const [url, opts] = args;
if (typeof url === 'string' && url.includes('/rpc') && opts?.body) {
try {
const body = JSON.parse(opts.body);
(window as any).__e2eRpcLog.push({ method: body.method, params: body.params });
} catch (_e) {
/* intentionally empty — best-effort logging */
}
}
return origFetch.apply(this, args);
};
});
// Type a value and submit
const input = await $('#mcp-setup-secret-input');
await input.setValue('e2e_super_secret_value');
const submitBtn = await dialog.$('button[type="submit"]');
await submitBtn.click();
// Give it a moment to process
await browser.pause(1_000);
// Retrieve the RPC log
const rpcLog = (await browser.execute(() => (window as any).__e2eRpcLog)) as Array<{
method: string;
params: Record<string, unknown>;
}>;
// The submit_secret call should carry the ref and value
const submitCall = rpcLog.find(c => c.method === 'openhuman.mcp_setup_submit_secret');
expect(submitCall).toBeDefined();
expect(submitCall!.params.ref_id).toBe('secret://e2e_test_ref2');
expect(submitCall!.params.value).toBe('e2e_super_secret_value');
// No other RPC call should contain the raw secret
const otherCalls = rpcLog.filter(c => c.method !== 'openhuman.mcp_setup_submit_secret');
for (const call of otherCalls) {
const serialized = JSON.stringify(call.params);
expect(serialized).not.toContain('e2e_super_secret_value');
}
// Cleanup: restore original fetch
await browser.execute(() => {
delete (window as any).__e2eRpcLog;
});
});
});
@@ -0,0 +1,153 @@
import { expect, test } from '@playwright/test';
import { bootRuntimeReadyGuestPage } from '../helpers/core-rpc';
test.describe('MCP Setup — Secret Collection Flow', () => {
test('SecretPromptDialog appears on event, collects input, and submits without leaking to agent context', async ({
page,
}) => {
await bootRuntimeReadyGuestPage(page);
// Intercept all RPC calls to track what gets sent.
// Mock mcp_setup_submit_secret since there's no real SecretRef in core memory.
const rpcCalls: Array<{ method: string; params: Record<string, unknown> }> = [];
await page.route('**/rpc', async (route, request) => {
const body = JSON.parse(request.postData() || '{}');
rpcCalls.push({ method: body.method, params: body.params });
if (body.method === 'openhuman.mcp_setup_submit_secret') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
jsonrpc: '2.0',
id: body.id,
result: { ref: body.params.ref_id, fulfilled: true },
}),
});
} else {
await route.continue();
}
});
// Simulate the core publishing a McpSetupSecretRequested event.
// In production this comes via the socket → window event bridge.
await page.evaluate(() => {
window.dispatchEvent(
new CustomEvent('openhuman:mcp-setup-secret-requested', {
detail: {
refId: 'secret://aabbccdd1122',
keyName: 'NOTION_API_KEY',
prompt: 'Enter your Notion integration token to connect.',
},
})
);
});
// The dialog should appear
const dialog = page.locator('[role="dialog"]');
await expect(dialog).toBeVisible({ timeout: 5_000 });
// Should show the key name and prompt
await expect(dialog.locator('code')).toContainText('NOTION_API_KEY');
await expect(dialog).toContainText('Enter your Notion integration token to connect.');
// The input should be type=password (not leaking visually)
const input = dialog.locator('input[type="password"]');
await expect(input).toBeVisible();
// Type a secret value
await input.fill('ntn_secret_test_value_12345');
// Submit
const submitButton = dialog.locator('button[type="submit"]');
await expect(submitButton).toBeEnabled();
await submitButton.click();
// Dialog should dismiss
await expect(dialog).not.toBeVisible({ timeout: 5_000 });
// Verify the RPC call was made with the correct ref but the value
// only goes to submit_secret (never to any agent-facing method)
const submitCall = rpcCalls.find(c => c.method === 'openhuman.mcp_setup_submit_secret');
expect(submitCall).toBeTruthy();
expect(submitCall!.params.ref_id).toBe('secret://aabbccdd1122');
expect(submitCall!.params.value).toBe('ntn_secret_test_value_12345');
// Critically: no agent-facing RPC (mcp_setup_test_connection,
// mcp_setup_install_and_connect, or any chat/thread method) should
// contain the raw secret value in its params.
const agentFacingCalls = rpcCalls.filter(
c =>
c.method !== 'openhuman.mcp_setup_submit_secret' &&
c.method !== 'openhuman.auth_store_session' &&
c.method !== 'openhuman.auth_clear_session' &&
c.method !== 'openhuman.config_set_onboarding_completed'
);
for (const call of agentFacingCalls) {
const serialized = JSON.stringify(call.params);
expect(serialized).not.toContain('ntn_secret_test_value_12345');
}
});
test('cancel does not submit the secret', async ({ page }) => {
await bootRuntimeReadyGuestPage(page);
const rpcCalls: Array<{ method: string }> = [];
await page.route('**/rpc', async (route, request) => {
const body = JSON.parse(request.postData() || '{}');
rpcCalls.push({ method: body.method });
await route.continue();
});
await page.evaluate(() => {
window.dispatchEvent(
new CustomEvent('openhuman:mcp-setup-secret-requested', {
detail: { refId: 'secret://cancel123456', keyName: 'API_KEY', prompt: 'Enter key' },
})
);
});
const dialog = page.locator('[role="dialog"]');
await expect(dialog).toBeVisible({ timeout: 5_000 });
// Click cancel
const cancelButton = dialog.getByRole('button', { name: /cancel/i });
await cancelButton.click();
// Dialog should dismiss
await expect(dialog).not.toBeVisible({ timeout: 3_000 });
// No submit_secret call should have been made
const submitCalls = rpcCalls.filter(c => c.method === 'openhuman.mcp_setup_submit_secret');
expect(submitCalls).toHaveLength(0);
});
test('secret input uses password masking by default', async ({ page }) => {
await bootRuntimeReadyGuestPage(page);
await page.evaluate(() => {
window.dispatchEvent(
new CustomEvent('openhuman:mcp-setup-secret-requested', {
detail: { refId: 'secret://mask123456', keyName: 'TOKEN', prompt: '' },
})
);
});
const dialog = page.locator('[role="dialog"]');
await expect(dialog).toBeVisible({ timeout: 5_000 });
// Input is password-type (masked)
const input = dialog.locator('#mcp-setup-secret-input');
await expect(input).toHaveAttribute('type', 'password');
// Toggle to show
const showButton = dialog.getByRole('button', { name: /show/i });
await showButton.click();
await expect(input).toHaveAttribute('type', 'text');
// Toggle back to hide
const hideButton = dialog.getByRole('button', { name: /hide/i });
await hideButton.click();
await expect(input).toHaveAttribute('type', 'password');
});
});
@@ -0,0 +1,484 @@
/**
* MCP Tab — full lifecycle e2e tests.
*
* Covers: browse catalog → search → install (env-key form) → verify in
* installed list → manage detail view → connect/disconnect → uninstall →
* verify removal. All RPC calls are mocked via page.route so no running
* core is required.
*/
import { expect, type Page, test } from '@playwright/test';
const APP_VERSION = '0.57.18';
// ---------------------------------------------------------------------------
// Mock data
// ---------------------------------------------------------------------------
const REGISTRY_SERVERS = [
{
qualified_name: 'io.github.test/memory-server',
display_name: 'Memory Server',
description: 'A test MCP server for memory operations',
icon_url: null,
use_count: 1200,
is_deployed: false,
source: 'mcp_official',
},
{
qualified_name: 'io.github.test/github-tools',
display_name: 'GitHub Tools',
description: 'MCP server for GitHub API integration',
icon_url: null,
use_count: 5600,
is_deployed: true,
source: 'mcp_official',
},
{
qualified_name: 'io.github.test/notion-connector',
display_name: 'Notion Connector',
description: 'Connect to Notion workspaces via MCP',
icon_url: null,
use_count: 980,
is_deployed: false,
source: 'mcp_official',
},
];
function makeInstalledServer(overrides: Partial<typeof INSTALLED_DEFAULT> = {}) {
return { ...INSTALLED_DEFAULT, ...overrides };
}
const INSTALLED_DEFAULT = {
server_id: 'srv_installed_1',
qualified_name: 'io.github.test/memory-server',
display_name: 'Memory Server',
description: 'A test MCP server for memory operations',
command_kind: 'node',
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-memory'],
env_keys: [],
installed_at: 1700000000,
enabled: true,
};
const STATUS_CONNECTED = {
server_id: 'srv_installed_1',
qualified_name: 'io.github.test/memory-server',
display_name: 'Memory Server',
status: 'connected' as const,
tool_count: 5,
};
const GITHUB_DETAIL = {
...REGISTRY_SERVERS[1],
connections: [{ type: 'stdio', published: true }],
required_env_keys: ['GITHUB_TOKEN'],
};
const GITHUB_INSTALLED = {
server_id: 'srv_github_1',
qualified_name: 'io.github.test/github-tools',
display_name: 'GitHub Tools',
description: 'MCP server for GitHub API integration',
command_kind: 'node',
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-github'],
env_keys: ['GITHUB_TOKEN'],
installed_at: 1700000100,
enabled: true,
};
// ---------------------------------------------------------------------------
// RPC mock layer — mutable state so tests can drive lifecycle transitions
// ---------------------------------------------------------------------------
interface MockState {
installed: (typeof INSTALLED_DEFAULT)[];
statuses: (typeof STATUS_CONNECTED)[];
}
function rpcOk(id: number, result: unknown) {
return {
status: 200,
contentType: 'application/json',
body: JSON.stringify({ jsonrpc: '2.0', id, result }),
};
}
function rpcError(id: number, message: string) {
return {
status: 200,
contentType: 'application/json',
body: JSON.stringify({ jsonrpc: '2.0', id, error: { code: -32000, message } }),
};
}
async function setupMockRpc(page: Page, state: MockState) {
await page.route('**/rpc', async (route, request) => {
const body = JSON.parse(request.postData() || '{}');
const method: string = body.method;
const id: number = body.id;
const params = body.params ?? {};
switch (method) {
case 'openhuman.update_version':
return route.fulfill(
rpcOk(id, {
result: {
version: APP_VERSION,
target_triple: 'x86_64-apple-darwin',
asset_prefix: '',
},
})
);
case 'openhuman.app_state_snapshot':
return route.fulfill(
rpcOk(id, {
result: {
auth: { isAuthenticated: true, userId: 'pw-mcp-user', user: null, profileId: null },
sessionToken: 'fake-session-token',
currentUser: { _id: 'pw-mcp-user', displayName: 'Test User' },
onboardingCompleted: true,
chatOnboardingCompleted: true,
analyticsEnabled: false,
meetAutoOrchestratorHandoff: false,
localState: {},
keyringStatus: { isUnlocked: true, hasPassphrase: false },
runtime: {
screenIntelligence: { enabled: false },
localAi: { enabled: false },
autocomplete: { enabled: false },
service: { running: false },
},
},
})
);
// ---- MCP registry ----
case 'openhuman.mcp_clients_registry_search': {
const query = (params.query ?? '').toLowerCase();
const filtered = query
? REGISTRY_SERVERS.filter(
s =>
s.display_name.toLowerCase().includes(query) ||
s.qualified_name.toLowerCase().includes(query)
)
: REGISTRY_SERVERS;
return route.fulfill(rpcOk(id, { servers: filtered, page: 1, total_pages: 1 }));
}
case 'openhuman.mcp_clients_registry_get':
if (params.qualified_name === GITHUB_DETAIL.qualified_name) {
return route.fulfill(rpcOk(id, { server: GITHUB_DETAIL }));
}
return route.fulfill(rpcError(id, `server not found: ${params.qualified_name}`));
// ---- Installed servers (mutable) ----
case 'openhuman.mcp_clients_installed_list':
return route.fulfill(rpcOk(id, { installed: state.installed }));
case 'openhuman.mcp_clients_status':
return route.fulfill(rpcOk(id, { servers: state.statuses }));
case 'openhuman.mcp_clients_install':
if (!params.qualified_name) {
return route.fulfill(rpcError(id, "missing required param 'qualified_name'"));
}
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: [] }));
case 'openhuman.mcp_clients_disconnect':
state.statuses = state.statuses.filter(s => s.server_id !== params.server_id);
return route.fulfill(rpcOk(id, { status: 'disconnected' }));
case 'openhuman.mcp_clients_uninstall':
state.installed = state.installed.filter(s => s.server_id !== params.server_id);
state.statuses = state.statuses.filter(s => s.server_id !== params.server_id);
return route.fulfill(rpcOk(id, { success: true }));
case 'openhuman.mcp_clients_tools':
return route.fulfill(
rpcOk(id, {
tools: [
{ name: 'create_memory', description: 'Create a memory', input_schema: {} },
{ name: 'list_memories', description: 'List all memories', input_schema: {} },
],
})
);
default:
return route.fulfill(rpcOk(id, {}));
}
});
}
async function seedLocalStorage(page: Page) {
await page.addInitScript(() => {
window.localStorage.setItem('openhuman_core_mode', 'cloud');
window.localStorage.setItem('openhuman_core_rpc_url', 'http://127.0.0.1:17788/rpc');
window.localStorage.setItem('openhuman_core_rpc_token', 'test-token');
window.localStorage.setItem('openhuman:walkthrough_completed', 'true');
window.localStorage.removeItem('openhuman:walkthrough_pending');
});
}
async function navigateToMcpTab(page: Page) {
await page.goto('/#/skills?tab=mcp');
await page.waitForSelector('#root', { state: 'visible', timeout: 20_000 });
await page.locator('input[type="search"]').waitFor({ state: 'visible', timeout: 10_000 });
}
// ==========================================================================
// Tests
// ==========================================================================
test.describe('MCP Tab — Table View & Filtering', () => {
let state: MockState;
test.beforeEach(async ({ page }) => {
state = { installed: [makeInstalledServer()], statuses: [{ ...STATUS_CONNECTED }] };
await seedLocalStorage(page);
await setupMockRpc(page, state);
await navigateToMcpTab(page);
});
test('renders search bar and filter chips', async ({ page }) => {
await expect(page.locator('input[type="search"]')).toBeVisible();
await expect(page.getByRole('button', { name: /^All$/ })).toBeVisible();
await expect(page.getByRole('button', { name: /Installed/ })).toBeVisible();
await expect(page.getByRole('button', { name: /Registry/ })).toBeVisible();
});
test('displays installed servers with status dot and chip', async ({ page }) => {
const row = page.locator('table tbody tr').first();
await expect(row.locator('td:first-child')).toContainText('Memory Server');
await expect(row.locator('span:has-text("Installed")')).toBeVisible();
});
test('displays registry servers with Install button', async ({ page }) => {
const installBtn = page.locator('table tbody button:has-text("Install")');
await expect(installBtn.first()).toBeVisible({ timeout: 10_000 });
});
test('filter "Installed" hides registry rows', async ({ page }) => {
await page.getByRole('button', { name: /Installed/ }).click();
const rows = page.locator('table tbody tr');
const count = await rows.count();
expect(count).toBeGreaterThan(0);
for (let i = 0; i < count; i++) {
await expect(rows.nth(i).locator('td:nth-child(3) span')).toContainText('Installed');
}
});
test('filter "Registry" hides installed rows', async ({ page }) => {
await page.getByRole('button', { name: /Registry/ }).click();
const rows = page.locator('table tbody tr');
const count = await rows.count();
expect(count).toBeGreaterThan(0);
for (let i = 0; i < count; i++) {
await expect(rows.nth(i).locator('td:nth-child(3) span')).toContainText('Registry');
}
});
test('already-installed servers are excluded from registry rows', async ({ page }) => {
await page.getByRole('button', { name: /Registry/ }).click();
const rows = page.locator('table tbody tr');
const count = await rows.count();
for (let i = 0; i < count; i++) {
const text = await rows.nth(i).locator('td:first-child').innerText();
expect(text).not.toContain('Memory Server');
}
});
test('search filters both installed and registry servers', async ({ page }) => {
const search = page.locator('input[type="search"]');
await search.fill('notion');
await page.waitForTimeout(500);
const rows = page.locator('table tbody tr');
const count = await rows.count();
expect(count).toBeGreaterThanOrEqual(1);
for (let i = 0; i < count; i++) {
const name = await rows.nth(i).locator('td:first-child').innerText();
expect(name.toLowerCase()).toContain('notion');
}
});
test('no Smithery branding visible anywhere', async ({ page }) => {
await page.waitForTimeout(1000);
const bodyText = await page.locator('body').innerText();
expect(bodyText.toLowerCase()).not.toContain('smithery');
});
});
test.describe('MCP Tab — Install Lifecycle', () => {
let state: MockState;
test.beforeEach(async ({ page }) => {
state = { installed: [makeInstalledServer()], statuses: [{ ...STATUS_CONNECTED }] };
await seedLocalStorage(page);
await setupMockRpc(page, state);
await navigateToMcpTab(page);
});
test('install flow: click Install → fill env → submit → appears installed', async ({ page }) => {
// 1. Click "Install" on GitHub Tools (a registry-only server)
const githubRow = page.locator('table tbody tr', {
has: page.locator('td:first-child:has-text("GitHub Tools")'),
});
await expect(githubRow).toBeVisible({ timeout: 10_000 });
await githubRow.locator('button:has-text("Install")').click();
// 2. Install dialog should show — with Back button and env key input
await expect(page.locator('button:has-text("Back")')).toBeVisible({ timeout: 5_000 });
const envInput = page.locator('#env-GITHUB_TOKEN');
await expect(envInput).toBeVisible({ timeout: 5_000 });
// 3. Fill in the env value
await envInput.fill('ghp_test_token_123');
// 4. Click "Install" submit button
const submitBtn = page.locator('button:has-text("Install"):not(:has-text("Back"))').last();
await submitBtn.click();
// 5. Should navigate to detail view (the installed server detail)
// Back button should still be visible in the detail view
await expect(page.locator('button:has-text("Back")')).toBeVisible({ timeout: 10_000 });
// 6. Go back and verify the server appears in the installed list
await page.locator('button:has-text("Back")').click();
await expect(page.locator('table')).toBeVisible({ timeout: 5_000 });
const installedGithub = page.locator('table tbody tr', {
has: page.locator('td:has-text("GitHub Tools")'),
});
await expect(installedGithub).toBeVisible({ timeout: 5_000 });
});
test('back button from install dialog returns to table', async ({ page }) => {
const installBtn = page.locator('table tbody button:has-text("Install")').first();
await installBtn.click();
await expect(page.locator('button:has-text("Back")')).toBeVisible({ timeout: 5_000 });
await page.locator('button:has-text("Back")').click();
await expect(page.locator('table')).toBeVisible({ timeout: 5_000 });
});
});
test.describe('MCP Tab — Manage & Uninstall Lifecycle', () => {
let state: MockState;
test.beforeEach(async ({ page }) => {
state = { installed: [makeInstalledServer()], statuses: [{ ...STATUS_CONNECTED }] };
await seedLocalStorage(page);
await setupMockRpc(page, state);
await navigateToMcpTab(page);
});
test('click installed server row → detail view shows server info', async ({ page }) => {
// Click on the installed "Memory Server" row
const row = page.locator('table tbody tr', {
has: page.locator('td:first-child:has-text("Memory Server")'),
});
await row.click();
// Should navigate to detail view with server name visible
await expect(page.locator('button:has-text("Back")')).toBeVisible({ timeout: 5_000 });
await expect(page.locator('text=Memory Server')).toBeVisible();
});
test('detail view shows qualified name', async ({ page }) => {
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("Back")')).toBeVisible({ timeout: 5_000 });
await expect(page.locator('text=io.github.test/memory-server')).toBeVisible();
});
test('uninstall flow: detail → confirm uninstall → returns to table', async ({ page }) => {
// Navigate to detail
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("Back")')).toBeVisible({ timeout: 5_000 });
// Click the uninstall button to reveal the confirmation step
const uninstallBtn = page.locator('button:has-text("Uninstall")');
await expect(uninstallBtn.first()).toBeVisible({ timeout: 5_000 });
await uninstallBtn.first().click();
// Wait for and click the confirmation button ("Yes, uninstall")
const confirmBtn = page.locator('button:has-text("Yes")');
await expect(confirmBtn.first()).toBeVisible({ timeout: 5_000 });
await confirmBtn.first().click();
// Should return to table view with the server removed from installed
await expect(page.locator('table')).toBeVisible({ timeout: 10_000 });
// Switch to Installed filter — the server should no longer appear
await page.getByRole('button', { name: /Installed/ }).click();
const removedRow = page.locator('table tbody tr', {
has: page.locator('td:first-child:has-text("Memory Server")'),
});
await expect(removedRow).toHaveCount(0, { timeout: 5_000 });
});
test('back button from detail returns to table', async ({ page }) => {
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("Back")')).toBeVisible({ timeout: 5_000 });
await page.locator('button:has-text("Back")').click();
await expect(page.locator('table')).toBeVisible({ timeout: 5_000 });
});
});
test.describe('MCP Tab — Empty & Edge States', () => {
test('empty installed list shows appropriate message', async ({ page }) => {
const state: MockState = { installed: [], statuses: [] };
await seedLocalStorage(page);
await setupMockRpc(page, state);
await navigateToMcpTab(page);
await page.getByRole('button', { name: /Installed/ }).click();
// Should show empty state text
const emptyMsg = page.locator('text=/no.*servers|no.*installed/i');
await expect(emptyMsg).toBeVisible({ timeout: 5_000 });
});
test('search with no results shows no-results message', async ({ page }) => {
const state: MockState = { installed: [], statuses: [] };
await seedLocalStorage(page);
await setupMockRpc(page, state);
// Override registry search to return empty for specific query
await page.route('**/rpc', async (route, request) => {
const body = JSON.parse(request.postData() || '{}');
if (
body.method === 'openhuman.mcp_clients_registry_search' &&
body.params?.query === 'xyznonexistent999'
) {
return route.fulfill(rpcOk(body.id, { servers: [], page: 1, total_pages: 1 }));
}
await route.fallback();
});
await navigateToMcpTab(page);
await page.locator('input[type="search"]').fill('xyznonexistent999');
await page.waitForTimeout(500);
const noResults = page.locator('text=/no.*results|no.*found|no.*servers/i');
await expect(noResults).toBeVisible({ timeout: 5_000 });
});
});
@@ -53,9 +53,14 @@ test.describe('Skills registry flow', () => {
await expect(page.getByText(/Telegram|Discord|Slack/).first()).toBeVisible();
});
test('mcp tab shows the placeholder panel', async ({ page }) => {
test('mcp tab renders the server table', async ({ page }) => {
await page.getByRole('tab', { name: 'MCP Servers' }).click();
await expect(page.getByRole('heading', { name: 'MCP Servers' }).first()).toBeVisible();
await expect(page.getByText(/coming soon|early alpha|MCP/i).first()).toBeVisible();
await expect(
page
.getByRole('searchbox')
.or(page.getByPlaceholder(/search/i))
.first()
).toBeVisible();
await expect(page.getByText(/^All$|^Installed$|^Registry$/i).first()).toBeVisible();
});
});
+3
View File
@@ -37,6 +37,9 @@
mod client;
mod registry;
pub mod sanitize;
pub mod setup_agent;
#[cfg(test)]
mod setup_agent_integration_test;
mod stdio;
pub use client::{
+312
View File
@@ -0,0 +1,312 @@
//! One-shot MCP server setup: search, resolve, test, and report.
//!
//! This module provides a high-level [`setup_from_registry`] function that
//! given a server name (or search query), resolves its install command from
//! the official MCP registry, spawns a scratch stdio session to verify the
//! server responds, and returns a [`SetupResult`] with everything needed
//! to persist the install.
//!
//! It is intentionally transport-only — no persistence, no event-bus, no
//! RPC surface. The sibling `mcp_registry` module consumes this for its
//! full lifecycle management; agent tools and the UI can also call it
//! directly for a "dry-run" test before committing an install.
use std::collections::HashMap;
use std::path::PathBuf;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::McpStdioClient;
use crate::openhuman::config::McpClientIdentityConfig;
/// Input parameters for setting up an MCP server.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetupRequest {
/// The command to launch (e.g. `npx`, `uvx`, `node`, `python`).
pub command: String,
/// Arguments passed to the command (e.g. `["-y", "@scope/pkg"]`).
pub args: Vec<String>,
/// Environment variables to inject into the subprocess.
#[serde(default)]
pub env: HashMap<String, String>,
/// Optional working directory for the subprocess.
#[serde(default)]
pub cwd: Option<PathBuf>,
}
/// Result of a successful setup test.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetupResult {
/// Server name reported in the `initialize` handshake.
pub server_name: String,
/// Server version reported in the `initialize` handshake.
pub server_version: String,
/// Protocol version negotiated.
pub protocol_version: String,
/// Tools the server exposes.
pub tools: Vec<SetupTool>,
}
/// A tool discovered during setup.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetupTool {
pub name: String,
pub description: Option<String>,
}
/// Spawn a scratch MCP stdio session, perform the `initialize` handshake,
/// list tools, and tear down. Returns the discovered server info and tools
/// on success.
///
/// This is a side-effect-free probe — nothing is persisted or registered.
pub async fn test_connection(
req: &SetupRequest,
identity: McpClientIdentityConfig,
) -> Result<SetupResult> {
let env: Vec<(String, String)> = req
.env
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
tracing::debug!(
"[mcp-setup-agent] test_connection command={} args={:?}",
req.command,
req.args
);
let client = McpStdioClient::new(
req.command.clone(),
req.args.clone(),
env,
req.cwd.clone(),
identity,
);
let init = match client.initialize().await {
Ok(init) => init,
Err(err) => {
let _ = client.close_session().await;
return Err(err).context("MCP server failed to initialize");
}
};
let tools = match client.list_tools().await {
Ok(tools) => tools,
Err(err) => {
let _ = client.close_session().await;
return Err(err).context("MCP server failed to list tools");
}
};
let _ = client.close_session().await;
let server_name = init
.server_info
.get("name")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string();
let server_version = init
.server_info
.get("version")
.and_then(Value::as_str)
.unwrap_or("0.0.0")
.to_string();
let setup_tools: Vec<SetupTool> = tools
.into_iter()
.map(|t| SetupTool {
name: t.name,
description: t.description,
})
.collect();
tracing::info!(
"[mcp-setup-agent] test_connection ok server={} version={} tools={}",
server_name,
server_version,
setup_tools.len()
);
Ok(SetupResult {
server_name,
server_version,
protocol_version: init.protocol_version,
tools: setup_tools,
})
}
/// Resolve the install command for a package from the official MCP registry
/// response shape. Given `registry_type` and `identifier`, returns the
/// appropriate [`SetupRequest`].
///
/// Supported registry types:
/// - `"npm"` → `npx -y <identifier>`
/// - `"pypi"` → `uvx <identifier>`
/// - Other → attempts `<identifier>` as a direct binary
pub fn resolve_setup_request(
registry_type: &str,
identifier: &str,
runtime_hint: Option<&str>,
runtime_args: &[String],
env: HashMap<String, String>,
) -> SetupRequest {
let (command, mut args) = match registry_type {
"pypi" => {
let cmd = runtime_hint.unwrap_or("uvx").to_string();
(cmd, Vec::new())
}
"npm" => {
let cmd = runtime_hint.unwrap_or("npx").to_string();
let default_args = if runtime_args.is_empty() {
vec!["-y".to_string()]
} else {
Vec::new()
};
(cmd, default_args)
}
_ => {
let cmd = runtime_hint
.map(String::from)
.unwrap_or_else(|| identifier.to_string());
(cmd, Vec::new())
}
};
for ra in runtime_args {
args.push(ra.clone());
}
if registry_type == "npm" || registry_type == "pypi" {
args.push(identifier.to_string());
}
SetupRequest {
command,
args,
env,
cwd: None,
}
}
/// Parse the official MCP registry server detail JSON and extract the best
/// package-based [`SetupRequest`]. Prefers npm, falls back to pypi, then
/// any other registryType.
pub fn setup_request_from_registry_detail(
detail: &Value,
env: HashMap<String, String>,
) -> Option<SetupRequest> {
let packages = detail.get("packages").and_then(Value::as_array)?;
let pick = packages
.iter()
.find(|p| p.get("registryType").and_then(Value::as_str) == Some("npm"))
.or_else(|| {
packages
.iter()
.find(|p| p.get("registryType").and_then(Value::as_str) == Some("pypi"))
})
.or_else(|| packages.first())?;
let registry_type = pick
.get("registryType")
.and_then(Value::as_str)
.unwrap_or("npm");
let identifier = pick.get("identifier").and_then(Value::as_str)?;
let runtime_hint = pick.get("runtimeHint").and_then(Value::as_str);
let runtime_args: Vec<String> = pick
.get("runtimeArguments")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|v| v.get("value").and_then(Value::as_str))
.map(String::from)
.collect()
})
.unwrap_or_default();
Some(resolve_setup_request(
registry_type,
identifier,
runtime_hint,
&runtime_args,
env,
))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn resolve_npm_package_with_runtime_args() {
let req = resolve_setup_request(
"npm",
"@scope/my-server",
Some("npx"),
&["-y".into()],
HashMap::new(),
);
assert_eq!(req.command, "npx");
assert_eq!(req.args, vec!["-y", "@scope/my-server"]);
}
#[test]
fn resolve_npm_package_default_args() {
let req = resolve_setup_request("npm", "@scope/my-server", None, &[], HashMap::new());
assert_eq!(req.command, "npx");
assert_eq!(req.args, vec!["-y", "@scope/my-server"]);
}
#[test]
fn resolve_pypi_package() {
let req = resolve_setup_request("pypi", "mcp-server-time", None, &[], HashMap::new());
assert_eq!(req.command, "uvx");
assert_eq!(req.args, vec!["mcp-server-time"]);
}
#[test]
fn resolve_unknown_registry_uses_identifier() {
let req = resolve_setup_request("docker", "my-image", None, &[], HashMap::new());
assert_eq!(req.command, "my-image");
assert!(req.args.is_empty());
}
#[test]
fn setup_request_from_detail_picks_npm_over_pypi() {
let detail = json!({
"packages": [
{ "registryType": "pypi", "identifier": "py-server" },
{ "registryType": "npm", "identifier": "@org/node-server", "runtimeHint": "npx", "runtimeArguments": [{"value": "-y"}] },
]
});
let req = setup_request_from_registry_detail(&detail, HashMap::new()).unwrap();
assert_eq!(req.command, "npx");
// runtime_args=["-y"] provided, so no default -y added; then identifier appended
assert_eq!(req.args, vec!["-y", "@org/node-server"]);
}
#[test]
fn setup_request_from_detail_falls_back_to_pypi() {
let detail = json!({
"packages": [
{ "registryType": "pypi", "identifier": "my-mcp" },
]
});
let req = setup_request_from_registry_detail(&detail, HashMap::new()).unwrap();
assert_eq!(req.command, "uvx");
assert_eq!(req.args, vec!["my-mcp"]);
}
#[test]
fn setup_request_with_env() {
let mut env = HashMap::new();
env.insert("API_KEY".into(), "secret123".into());
let req = resolve_setup_request("npm", "my-server", None, &[], env);
assert_eq!(req.env.get("API_KEY").unwrap(), "secret123");
}
}
@@ -0,0 +1,69 @@
//! Integration tests for the MCP setup agent.
//!
//! These tests hit real MCP servers via stdio (npx/uvx) and verify the
//! full handshake + tool discovery works end-to-end. They require network
//! access and the `npx`/`uvx` binaries to be available.
//!
//! Run with: `cargo test --lib -- setup_agent_integration --ignored`
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use crate::openhuman::config::McpClientIdentityConfig;
use crate::openhuman::mcp_client::setup_agent::{resolve_setup_request, test_connection};
fn test_identity() -> McpClientIdentityConfig {
McpClientIdentityConfig {
name: "openhuman-test".to_string(),
title: "OpenHuman Test".to_string(),
version: "0.0.1".to_string(),
}
}
#[tokio::test]
#[ignore]
async fn npm_memory_server_connects_and_lists_tools() {
let req = resolve_setup_request(
"npm",
"@modelcontextprotocol/server-memory",
Some("npx"),
&["-y".to_string()],
HashMap::new(),
);
assert_eq!(req.command, "npx");
let result = test_connection(&req, test_identity())
.await
.expect("npm memory server should connect");
assert_eq!(result.server_name, "memory-server");
assert!(
!result.tools.is_empty(),
"memory server should expose tools"
);
assert!(
result
.tools
.iter()
.any(|t| t.name == "read_graph" || t.name == "create_entities"),
"expected knowledge-graph tools, got: {:?}",
result.tools.iter().map(|t| &t.name).collect::<Vec<_>>()
);
}
#[tokio::test]
#[ignore]
async fn pypi_time_server_connects_and_lists_tools() {
let req = resolve_setup_request("pypi", "mcp-server-time", None, &[], HashMap::new());
assert_eq!(req.command, "uvx");
assert_eq!(req.args, vec!["mcp-server-time"]);
let result = test_connection(&req, test_identity())
.await
.expect("pypi time server should connect");
assert_eq!(result.server_name, "mcp-time");
assert!(!result.tools.is_empty(), "time server should expose tools");
}
}
@@ -5,8 +5,8 @@
//!
//! Endpoints used:
//! - `GET /v0/servers?search=<query>&limit=<n>&cursor=<opt>` — paginated list
//! - `GET /v0/servers/{name}` — full detail for one server (or a fallback
//! path that searches by exact name when the direct endpoint 404s)
//! - `GET /v0/servers/{name}/versions` — all versions for one server; the
//! `get` method picks the first (latest) entry
//!
//! ## Pagination model
//!
@@ -184,9 +184,13 @@ impl Registry for McpOfficialRegistry {
}
}
// The official registry has no single-server endpoint. Use the
// versions endpoint (`/v0/servers/{name}/versions`) which returns
// the same envelope shape as the list endpoint, then pick the
// latest version.
let client = http_client()?;
let url = format!(
"{}/v0/servers/{}",
"{}/v0/servers/{}/versions",
base_url(config),
urlencoding_encode(qualified_name)
);
@@ -207,9 +211,26 @@ impl Registry for McpOfficialRegistry {
);
}
let server: OfficialServer = serde_json::from_str(&body)
.with_context(|| format!("Failed to parse MCP official detail: {body}"))?;
let _ = store::set_cached(config, &cache_key, &body);
// The versions endpoint returns the same envelope array as the
// list endpoint. Extract the raw JSON for the first (latest)
// server object and cache it so subsequent calls skip the HTTP
// round-trip.
let raw: Value = serde_json::from_str(&body)
.with_context(|| format!("Failed to re-parse MCP official versions: {body}"))?;
let server_value = raw
.pointer("/servers/0/server")
.ok_or_else(|| anyhow::anyhow!("no versions found for {qualified_name}"))?;
let server_json = server_value.to_string();
let _ = store::set_cached(config, &cache_key, &server_json);
let server: OfficialServer = serde_json::from_value(server_value.clone())
.with_context(|| format!("Failed to parse MCP official server: {server_json}"))?;
tracing::debug!(
"[mcp-official] get ok qualified_name={} packages={} remotes={}",
server.name,
server.packages.len(),
server.remotes.len()
);
Ok(server.into_detail())
}
}
@@ -444,9 +465,16 @@ struct OfficialListResponse {
impl OfficialListResponse {
fn into_summaries(self) -> Vec<SmitheryServerSummary> {
let mut seen = std::collections::HashSet::new();
self.servers
.into_iter()
.map(|env| env.server.into_summary())
.filter_map(|env| {
if seen.insert(env.server.name.clone()) {
Some(env.server.into_summary())
} else {
None
}
})
.collect()
}
@@ -496,6 +524,9 @@ struct OfficialServer {
/// Reverse-DNS-style identifier, e.g. `io.github.foo/server-bar`.
#[serde(default)]
name: String,
/// Human-friendly title (e.g. "Notion MCP"). Falls back to `name` when absent.
#[serde(default)]
title: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default, rename = "iconUrl")]
@@ -509,10 +540,27 @@ struct OfficialServer {
}
impl OfficialServer {
fn display_name(&self) -> String {
if let Some(title) = self.title.as_deref().filter(|s| !s.trim().is_empty()) {
return title.to_string();
}
// Derive a readable name from the qualified name. The registry uses
// reverse-DNS like `io.github.user/server-name` — take the last
// segment after `/` if present, else after the last `.`.
let raw = &self.name;
let segment = raw
.rsplit_once('/')
.map(|(_, s)| s)
.or_else(|| raw.rsplit_once('.').map(|(_, s)| s))
.unwrap_or(raw);
segment.replace('-', " ").replace('_', " ")
}
fn into_summary(self) -> SmitheryServerSummary {
let display = self.display_name();
SmitheryServerSummary {
qualified_name: self.name.clone(),
display_name: self.name.clone(),
display_name: display,
description: self.description.clone(),
icon_url: self.icon_url.clone(),
use_count: 0,
@@ -523,6 +571,7 @@ impl OfficialServer {
}
fn into_detail(self) -> SmitheryServerDetail {
let display = self.display_name();
let mut connections: Vec<SmitheryConnection> = Vec::new();
for r in &self.remotes {
connections.push(SmitheryConnection {
@@ -538,15 +587,15 @@ impl OfficialServer {
connections.push(SmitheryConnection {
r#type: "stdio".to_string(),
deployment_url: None,
config_schema: p.config_schema.clone(),
example_config: None,
config_schema: p.to_config_schema(),
example_config: p.to_example_config(),
published: true,
extra: std::collections::HashMap::new(),
});
}
SmitheryServerDetail {
qualified_name: self.name.clone(),
display_name: self.name.clone(),
display_name: display,
description: self.description.clone(),
icon_url: self.icon_url.clone(),
connections,
@@ -564,10 +613,109 @@ struct OfficialRemote {
#[derive(Debug, Clone, Deserialize)]
struct OfficialPackage {
#[serde(default, rename = "registryType")]
registry_type: Option<String>,
#[serde(default)]
identifier: Option<String>,
#[serde(default, rename = "runtimeHint")]
runtime_hint: Option<String>,
#[serde(default, rename = "runtimeArguments")]
runtime_arguments: Vec<OfficialRuntimeArg>,
#[serde(default, rename = "environmentVariables")]
environment_variables: Vec<OfficialEnvVar>,
#[serde(default, rename = "configSchema")]
config_schema: Option<Value>,
}
#[derive(Debug, Clone, Deserialize)]
struct OfficialRuntimeArg {
#[serde(default)]
value: Option<String>,
#[serde(default, rename = "type")]
arg_type: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct OfficialEnvVar {
#[serde(default)]
name: String,
#[serde(default)]
description: Option<String>,
#[serde(default, rename = "isRequired")]
is_required: Option<bool>,
#[serde(default, rename = "isSecret")]
is_secret: Option<bool>,
}
impl OfficialPackage {
fn to_example_config(&self) -> Option<Value> {
let (command, mut args) = match self.registry_type.as_deref() {
Some("pypi") => {
let cmd = self.runtime_hint.as_deref().unwrap_or("uvx");
(cmd.to_string(), Vec::new())
}
Some("npm") => {
let cmd = self.runtime_hint.as_deref().unwrap_or("npx");
let default_args = if self.runtime_arguments.is_empty() {
vec!["-y".to_string()]
} else {
Vec::new()
};
(cmd.to_string(), default_args)
}
_ => {
let cmd = self.runtime_hint.as_deref().unwrap_or("npx");
(cmd.to_string(), vec!["-y".to_string()])
}
};
for ra in &self.runtime_arguments {
if let Some(v) = &ra.value {
args.push(v.clone());
}
}
if let Some(id) = &self.identifier {
args.push(id.clone());
}
Some(serde_json::json!({
"command": command,
"args": args,
}))
}
fn to_config_schema(&self) -> Option<Value> {
if !self.environment_variables.is_empty() {
let mut properties = serde_json::Map::new();
for ev in &self.environment_variables {
let mut prop = serde_json::Map::new();
if let Some(desc) = &ev.description {
prop.insert("description".into(), Value::String(desc.clone()));
}
if ev.is_secret == Some(true) {
prop.insert("x-secret".into(), Value::Bool(true));
}
properties.insert(ev.name.clone(), Value::Object(prop));
}
let required: Vec<Value> = self
.environment_variables
.iter()
.filter(|e| e.is_required == Some(true))
.map(|e| Value::String(e.name.clone()))
.collect();
let mut schema = serde_json::Map::new();
schema.insert("properties".into(), Value::Object(properties));
if !required.is_empty() {
schema.insert("required".into(), Value::Array(required));
}
Some(Value::Object(schema))
} else {
self.config_schema.clone()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -764,4 +912,90 @@ mod tests {
config.mcp_client.registry_auth.mcp_official_token = Some("tok-config".to_string());
assert_eq!(auth_token(&config).as_deref(), Some("tok-config"));
}
#[test]
fn npm_package_generates_npx_example_config() {
let pkg: OfficialPackage = serde_json::from_value(json!({
"registryType": "npm",
"identifier": "remote-filesystem-mcp-server",
"runtimeHint": "npx",
"runtimeArguments": [{ "value": "-y", "type": "positional" }],
}))
.unwrap();
let cfg = pkg.to_example_config().unwrap();
assert_eq!(cfg["command"], "npx");
let args: Vec<&str> = cfg["args"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert_eq!(args, vec!["-y", "remote-filesystem-mcp-server"]);
}
#[test]
fn pypi_package_generates_uvx_example_config() {
let pkg: OfficialPackage = serde_json::from_value(json!({
"registryType": "pypi",
"identifier": "files-com-mcp",
}))
.unwrap();
let cfg = pkg.to_example_config().unwrap();
assert_eq!(cfg["command"], "uvx");
let args: Vec<&str> = cfg["args"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert_eq!(args, vec!["files-com-mcp"]);
}
#[test]
fn package_env_vars_become_config_schema_properties() {
let pkg: OfficialPackage = serde_json::from_value(json!({
"registryType": "npm",
"identifier": "test-server",
"environmentVariables": [
{ "name": "API_KEY", "description": "Your API key", "isRequired": true, "isSecret": true },
{ "name": "REGION", "description": "AWS region" },
],
}))
.unwrap();
let schema = pkg.to_config_schema().unwrap();
let props = schema["properties"].as_object().unwrap();
assert!(props.contains_key("API_KEY"));
assert!(props.contains_key("REGION"));
assert_eq!(props["API_KEY"]["x-secret"], true);
let required: Vec<&str> = schema["required"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert_eq!(required, vec!["API_KEY"]);
}
#[test]
fn into_detail_populates_example_config_for_packages() {
let server: OfficialServer = serde_json::from_value(json!({
"name": "com.test/pypi-server",
"packages": [{
"registryType": "pypi",
"identifier": "my-mcp-server",
"environmentVariables": [
{ "name": "TOKEN", "isRequired": true }
],
}],
}))
.unwrap();
let detail = server.into_detail();
assert_eq!(detail.connections.len(), 1);
let conn = &detail.connections[0];
assert_eq!(conn.r#type, "stdio");
let example = conn.example_config.as_ref().unwrap();
assert_eq!(example["command"], "uvx");
let config = conn.config_schema.as_ref().unwrap();
assert!(config["properties"]["TOKEN"].is_object());
}
}
+10 -6
View File
@@ -1,5 +1,7 @@
//! Upstream MCP registries. Today: Smithery.ai and the official
//! [modelcontextprotocol/registry](https://github.com/modelcontextprotocol/registry).
//! Upstream MCP registries.
//!
//! Primary: the official [modelcontextprotocol.io registry](https://registry.modelcontextprotocol.io/docs).
//! Fallback: Smithery.ai (legacy, kept for servers not yet listed on the official registry).
//!
//! All registries implement the [`Registry`] trait and return results in the
//! canonical [`super::types::SmitheryServerSummary`] /
@@ -9,8 +11,8 @@
//! field so the frontend can render provenance).
//!
//! [`enabled_registries`] returns every registry that should participate in a
//! query. Today both registries are always enabled; this will become
//! config-driven once the wider scope lands.
//! query. The official registry is listed first so its results appear at the
//! top of merged search results and its `get` resolves first.
use anyhow::Result;
use async_trait::async_trait;
@@ -54,11 +56,13 @@ pub trait Registry: Send + Sync {
async fn get(&self, config: &Config, qualified_name: &str) -> Result<SmitheryServerDetail>;
}
/// All registries currently enabled for the user. Today: Smithery + official.
/// All registries currently enabled for the user.
/// Official modelcontextprotocol.io is primary; Smithery is a fallback for
/// servers not yet listed on the official registry.
pub fn enabled_registries() -> Vec<Box<dyn Registry>> {
vec![
Box::new(smithery::SmitheryRegistry),
Box::new(mcp_official::McpOfficialRegistry),
Box::new(smithery::SmitheryRegistry),
]
}
+2 -1
View File
@@ -3,7 +3,8 @@
//! `registry_search` fans out to every registry in
//! [`super::registries::enabled_registries`], runs them in parallel, and
//! returns merged results (failed registries are logged and skipped so one
//! flaky upstream doesn't blank the UI).
//! flaky upstream doesn't blank the UI). The official modelcontextprotocol.io
//! registry is listed first so its results take priority.
//!
//! `registry_get` routes by [`super::types::SmitheryServerDetail::source`].
//! The caller can pass an explicit source prefix using
+1 -1
View File
@@ -1155,7 +1155,7 @@ fn read_optional_json(params: &Map<String, Value>, key: &str) -> Result<Option<V
}
fn to_json<T: serde::Serialize>(outcome: RpcOutcome<T>) -> Result<Value, String> {
outcome.into_cli_compatible_json()
serde_json::to_value(outcome.value).map_err(|e| e.to_string())
}
fn type_name(value: &Value) -> &'static str {
+12 -13
View File
@@ -8963,11 +8963,10 @@ async fn mcp_clients_lifecycle() {
.await;
let tc_result =
assert_no_jsonrpc_error(&tool_call_disconnected, "tool_call on disconnected server");
let tc_body = tc_result.get("result").unwrap_or(tc_result);
assert_eq!(
tc_body.get("is_error"),
tc_result.get("is_error"),
Some(&json!(true)),
"tool_call on disconnected server should set is_error=true: {tc_body}"
"tool_call on disconnected server should set is_error=true: {tc_result}"
);
// ── 7. disconnect on a non-connected server is a no-op ────────────────────
@@ -9103,15 +9102,15 @@ async fn mcp_clients_install_connect_tool_call_happy_path() {
)
.await;
let tc_result = assert_no_jsonrpc_error(&tool_call, "mcp_clients_tool_call (happy path)");
let tc_body = tc_result.get("result").unwrap_or(tc_result);
assert_eq!(
tc_body.get("is_error"),
tc_result.get("is_error"),
Some(&json!(false)),
"echo tool_call should not be an error: {tc_body}"
"echo tool_call should not be an error: {tc_result}"
);
let tc_inner = tc_result.get("result").unwrap_or(tc_result);
assert!(
tc_body.to_string().contains("hello over rpc"),
"echo tool_call should round-trip the input payload: {tc_body}"
tc_inner.to_string().contains("hello over rpc"),
"echo tool_call should round-trip the input payload: {tc_inner}"
);
// ── 4. update_env reconfigures + reconnects (no uninstall/reinstall) ─────
@@ -9152,15 +9151,15 @@ async fn mcp_clients_install_connect_tool_call_happy_path() {
.await;
let tc2_result =
assert_no_jsonrpc_error(&tool_call2, "mcp_clients_tool_call (after update_env)");
let tc2_body = tc2_result.get("result").unwrap_or(tc2_result);
assert_eq!(
tc2_body.get("is_error"),
tc2_result.get("is_error"),
Some(&json!(false)),
"echo tool_call after reconfigure should not be an error: {tc2_body}"
"echo tool_call after reconfigure should not be an error: {tc2_result}"
);
let tc2_inner = tc2_result.get("result").unwrap_or(tc2_result);
assert!(
tc2_body.to_string().contains("hello after reconfigure"),
"echo tool_call after reconfigure should round-trip the input payload: {tc2_body}"
tc2_inner.to_string().contains("hello after reconfigure"),
"echo tool_call after reconfigure should round-trip the input payload: {tc2_inner}"
);
// ── 5. disconnect cleans up the subprocess ───────────────────────────────