mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(mcp-registry): redesign MCP tab with official registry as primary source (#3480)
This commit is contained in:
@@ -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}
|
||||
|
||||
+4
-4
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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}',
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 ───────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user