diff --git a/app/src/components/channels/mcp/InstallDialog.tsx b/app/src/components/channels/mcp/InstallDialog.tsx index eec962212..ae2162c32 100644 --- a/app/src/components/channels/mcp/InstallDialog.tsx +++ b/app/src/components/channels/mcp/InstallDialog.tsx @@ -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. diff --git a/app/src/components/channels/mcp/McpCatalogBrowser.test.tsx b/app/src/components/channels/mcp/McpCatalogBrowser.test.tsx index 5204b3af1..cdcb107c7 100644 --- a/app/src/components/channels/mcp/McpCatalogBrowser.test.tsx +++ b/app/src/components/channels/mcp/McpCatalogBrowser.test.tsx @@ -23,14 +23,14 @@ describe('McpCatalogBrowser', () => { it('renders search input', async () => { mockRegistrySearch.mockResolvedValue({ servers: [], page: 1, total_pages: 1 }); render( {}} />); - 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( {}} />); - 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(); }); diff --git a/app/src/components/channels/mcp/McpCatalogBrowser.tsx b/app/src/components/channels/mcp/McpCatalogBrowser.tsx index 68ebd8cbd..6ba575e80 100644 --- a/app/src/components/channels/mcp/McpCatalogBrowser.tsx +++ b/app/src/components/channels/mcp/McpCatalogBrowser.tsx @@ -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) => { <>
{servers.map(server => ( - void; } -const SmitheryServerCard = ({ server, onInstall }: SmitheryServerCardProps) => { +const McpServerCard = ({ server, onInstall }: McpServerCardProps) => { const { t } = useT(); return (
@@ -61,4 +61,4 @@ const SmitheryServerCard = ({ server, onInstall }: SmitheryServerCardProps) => { ); }; -export default SmitheryServerCard; +export default McpServerCard; diff --git a/app/src/components/channels/mcp/McpServersTab.test.tsx b/app/src/components/channels/mcp/McpServersTab.test.tsx index 98577ebc1..f4934af59 100644 --- a/app/src/components/channels/mcp/McpServersTab.test.tsx +++ b/app/src/components/channels/mcp/McpServersTab.test.tsx @@ -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(); + // 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(); + 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(); + 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(); + 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(); + 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(); - 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(); + 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(); + 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(); - 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(); + 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(); - 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(); - 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(); - 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(); - 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(); - 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(); + 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(); + 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(); + 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(); + 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(); + 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(); }); }); }); diff --git a/app/src/components/channels/mcp/McpServersTab.tsx b/app/src/components/channels/mcp/McpServersTab.tsx index 83cb4e5a6..ea99d1ae5 100644 --- a/app/src/components/channels/mcp/McpServersTab.tsx +++ b/app/src/components/channels/mcp/McpServersTab.tsx @@ -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 }; +type FilterChip = 'all' | 'installed' | 'registry'; + +const STATUS_DOT: Record = { + 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([]); const [statuses, setStatuses] = useState([]); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(null); - const [rightPane, setRightPane] = useState({ 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({ mode: 'home' }); const [inventoryOpen, setInventoryOpen] = useState(false); + + // Unified search + filter + const [searchQuery, setSearchQuery] = useState(''); + const [activeChip, setActiveChip] = useState('all'); + + // Registry catalog results + const [catalogServers, setCatalogServers] = useState([]); + const [catalogLoading, setCatalogLoading] = useState(false); + const [catalogPage, setCatalogPage] = useState(1); + const [catalogTotalPages, setCatalogTotalPages] = useState(1); + const pollTimerRef = useRef | null>(null); + const debounceRef = useRef | 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[], 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 ( +
+ + void handleUninstalled(serverId)} + onEnabledChange={(serverId, enabled) => void handleEnabledChange(serverId, enabled)} + /> +
+ ); + } + + // Install view + if (view.mode === 'install') { + return ( +
+ + void handleInstallSuccess(server)} + onCancel={() => setView({ mode: 'home' })} + /> +
+ ); + } + + // Home view — unified table return ( -
-
-
- - {t('mcp.alphaBadge')} - - {t('mcp.alphaBannerText')} -
+
+ {/* Search + filter chips */} +
+ 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" + />
-
- {/* Left pane: health toolbar + search + installed list */} -
- {loadError && ( -
- {loadError} -
- )} - - {servers.length > 0 && ( -
- -
- )} - -
- {/* Right pane */} -
- {rightPane.mode === 'none' && ( -
- {t('mcp.tab.emptyDetail')} -
- )} - - {rightPane.mode === 'catalog' && ( - - )} - - {rightPane.mode === 'install' && ( - void handleInstallSuccess(server)} - onCancel={() => setRightPane({ mode: 'catalog' })} - /> - )} - - {rightPane.mode === 'detail' && selectedServer && ( - void handleUninstalled(serverId)} - onEnabledChange={(serverId, enabled) => void handleEnabledChange(serverId, enabled)} - /> - )} -
+ {/* Filter chips */} +
+ {(['all', 'installed', 'registry'] as FilterChip[]).map(chip => ( + + ))}
+ + {loadError && ( +
+ {loadError} +
+ )} + + {/* Table */} +
+ + + + + + + + + + + {/* Installed servers */} + {(activeChip === 'all' || activeChip === 'installed') && + filteredInstalled.map(server => { + const status: ServerStatus = + statusMap.get(server.server_id)?.status ?? 'disconnected'; + return ( + handleSelectServer(server.server_id)} + onKeyDown={e => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleSelectServer(server.server_id); + } + }}> + + + + + + ); + })} + + {/* Registry servers */} + {(activeChip === 'all' || activeChip === 'registry') && + filteredCatalog.map(server => ( + + + + + + + ))} + +
+ {t('mcp.tab.column.name')} + + {t('mcp.tab.column.description')} + + {t('mcp.tab.column.source')} + + {t('mcp.tab.column.action')} +
+
+ + + {server.display_name} + +
+
+ + {server.description ?? '—'} + + + + {t('mcp.tab.badge.installed')} + + + + {t('mcp.tab.action.manage')} + +
+
+ {server.icon_url ? ( + + ) : ( + + 🔌 + + )} + + {server.display_name} + +
+
+ + {server.description ?? '—'} + + + + {t('mcp.tab.badge.registry')} + + + +
+ + {/* Empty states */} + {activeChip === 'installed' && filteredInstalled.length === 0 && ( +
+ {t('mcp.installed.empty')} +
+ )} + {activeChip === 'registry' && filteredCatalog.length === 0 && !catalogLoading && ( +
+ {searchQuery + ? t('mcp.catalog.noResultsFor').replace('{query}', searchQuery) + : t('mcp.catalog.noResults')} +
+ )} + {activeChip === 'all' && + filteredInstalled.length === 0 && + filteredCatalog.length === 0 && + !catalogLoading && ( +
+ {searchQuery + ? t('mcp.catalog.noResultsFor').replace('{query}', searchQuery) + : t('mcp.catalog.noResults')} +
+ )} + + {/* Loading / load more */} + {catalogLoading && ( +
+ {t('common.loading')} +
+ )} + {!catalogLoading && + catalogPage < catalogTotalPages && + (activeChip === 'all' || activeChip === 'registry') && ( +
+ +
+ )} +
+ {inventoryOpen && ( { - // 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)} /> diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index ac21b0e72..343962ad6 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -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}', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 07e00211b..d38d5969a 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -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} ইনস্টল করুন', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 77e0c4f6e..069d645ad 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -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}', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 4ec593275..e40e95a55 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -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}', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 443b997f1..498e971aa 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -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}', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index e5a8168c7..cd9b97bed 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -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}', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index fe0ee1320..39bc1a7b3 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -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} स्थापित करें', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 39e70168a..32a83b9c1 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -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}', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index eb7bd1e8f..3f5278b87 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -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}', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 6239fc7f2..b8943700b 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -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} 설치', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index bf82c1deb..436de9bec 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -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}', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 001a6c298..bd592cb0d 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -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}', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 507c0f024..039204aaa 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -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}', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index b4319af97..6408ba8d0 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -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}', diff --git a/app/src/pages/Skills.tsx b/app/src/pages/Skills.tsx index b925ec5c4..e017e14f5 100644 --- a/app/src/pages/Skills.tsx +++ b/app/src/pages/Skills.tsx @@ -946,30 +946,8 @@ export default function Skills() { {activeTab === 'skills' && } {activeTab === 'mcp' && ( -
-
-

- {t('channels.mcp.title')} -

-

- {t('channels.mcp.description')} -

-
- {IS_DEV ? ( -
- -
- ) : ( -
-
🔌
-

- {t('misc.comingSoon')} -

-

- {t('channels.mcp.description')} -

-
- )} +
+
)} diff --git a/app/src/pages/__tests__/Skills.mcp-coming-soon.test.tsx b/app/src/pages/__tests__/Skills.mcp-coming-soon.test.tsx index 5e5f7c3b5..a666f14cf 100644 --- a/app/src/pages/__tests__/Skills.mcp-coming-soon.test.tsx +++ b/app/src/pages/__tests__/Skills.mcp-coming-soon.test.tsx @@ -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(, { 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(, { 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(, { 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(); }); }); }); diff --git a/app/src/services/api/mcpSetupApi.ts b/app/src/services/api/mcpSetupApi.ts index abe560c86..1744632d5 100644 --- a/app/src/services/api/mcpSetupApi.ts +++ b/app/src/services/api/mcpSetupApi.ts @@ -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; diff --git a/app/test/e2e/specs/mcp-setup-secret-dialog.spec.ts b/app/test/e2e/specs/mcp-setup-secret-dialog.spec.ts new file mode 100644 index 000000000..4065bf440 --- /dev/null +++ b/app/test/e2e/specs/mcp-setup-secret-dialog.spec.ts @@ -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; + }>; + + // 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; + }); + }); +}); diff --git a/app/test/playwright/specs/mcp-setup-secret-flow.spec.ts b/app/test/playwright/specs/mcp-setup-secret-flow.spec.ts new file mode 100644 index 000000000..c978f5814 --- /dev/null +++ b/app/test/playwright/specs/mcp-setup-secret-flow.spec.ts @@ -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 }> = []; + 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'); + }); +}); diff --git a/app/test/playwright/specs/mcp-tab-flow.spec.ts b/app/test/playwright/specs/mcp-tab-flow.spec.ts new file mode 100644 index 000000000..22054710e --- /dev/null +++ b/app/test/playwright/specs/mcp-tab-flow.spec.ts @@ -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 = {}) { + 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 }); + }); +}); diff --git a/app/test/playwright/specs/skills-registry.spec.ts b/app/test/playwright/specs/skills-registry.spec.ts index 24903c199..2ff844d5b 100644 --- a/app/test/playwright/specs/skills-registry.spec.ts +++ b/app/test/playwright/specs/skills-registry.spec.ts @@ -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(); }); }); diff --git a/src/openhuman/mcp_client/mod.rs b/src/openhuman/mcp_client/mod.rs index baf3c48b3..e7573b5dc 100644 --- a/src/openhuman/mcp_client/mod.rs +++ b/src/openhuman/mcp_client/mod.rs @@ -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::{ diff --git a/src/openhuman/mcp_client/setup_agent.rs b/src/openhuman/mcp_client/setup_agent.rs new file mode 100644 index 000000000..b2e71fcc0 --- /dev/null +++ b/src/openhuman/mcp_client/setup_agent.rs @@ -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, + /// Environment variables to inject into the subprocess. + #[serde(default)] + pub env: HashMap, + /// Optional working directory for the subprocess. + #[serde(default)] + pub cwd: Option, +} + +/// 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, +} + +/// A tool discovered during setup. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SetupTool { + pub name: String, + pub description: Option, +} + +/// 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 { + 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 = 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 ` +/// - `"pypi"` → `uvx ` +/// - Other → attempts `` as a direct binary +pub fn resolve_setup_request( + registry_type: &str, + identifier: &str, + runtime_hint: Option<&str>, + runtime_args: &[String], + env: HashMap, +) -> 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, +) -> Option { + 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 = 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"); + } +} diff --git a/src/openhuman/mcp_client/setup_agent_integration_test.rs b/src/openhuman/mcp_client/setup_agent_integration_test.rs new file mode 100644 index 000000000..1ca6c3c57 --- /dev/null +++ b/src/openhuman/mcp_client/setup_agent_integration_test.rs @@ -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::>() + ); + } + + #[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"); + } +} diff --git a/src/openhuman/mcp_registry/registries/mcp_official.rs b/src/openhuman/mcp_registry/registries/mcp_official.rs index 63e427b12..7f10fc5c9 100644 --- a/src/openhuman/mcp_registry/registries/mcp_official.rs +++ b/src/openhuman/mcp_registry/registries/mcp_official.rs @@ -5,8 +5,8 @@ //! //! Endpoints used: //! - `GET /v0/servers?search=&limit=&cursor=` — 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 { + 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, #[serde(default)] description: Option, #[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 = 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, + #[serde(default)] + identifier: Option, + #[serde(default, rename = "runtimeHint")] + runtime_hint: Option, + #[serde(default, rename = "runtimeArguments")] + runtime_arguments: Vec, + #[serde(default, rename = "environmentVariables")] + environment_variables: Vec, #[serde(default, rename = "configSchema")] config_schema: Option, } +#[derive(Debug, Clone, Deserialize)] +struct OfficialRuntimeArg { + #[serde(default)] + value: Option, + #[serde(default, rename = "type")] + arg_type: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct OfficialEnvVar { + #[serde(default)] + name: String, + #[serde(default)] + description: Option, + #[serde(default, rename = "isRequired")] + is_required: Option, + #[serde(default, rename = "isSecret")] + is_secret: Option, +} + +impl OfficialPackage { + fn to_example_config(&self) -> Option { + 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 { + 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 = 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()); + } } diff --git a/src/openhuman/mcp_registry/registries/mod.rs b/src/openhuman/mcp_registry/registries/mod.rs index 22c929ea1..4e48c0e25 100644 --- a/src/openhuman/mcp_registry/registries/mod.rs +++ b/src/openhuman/mcp_registry/registries/mod.rs @@ -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; } -/// 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> { vec![ - Box::new(smithery::SmitheryRegistry), Box::new(mcp_official::McpOfficialRegistry), + Box::new(smithery::SmitheryRegistry), ] } diff --git a/src/openhuman/mcp_registry/registry.rs b/src/openhuman/mcp_registry/registry.rs index 3be08d3bc..2c9feb2ea 100644 --- a/src/openhuman/mcp_registry/registry.rs +++ b/src/openhuman/mcp_registry/registry.rs @@ -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 diff --git a/src/openhuman/mcp_registry/schemas.rs b/src/openhuman/mcp_registry/schemas.rs index 32481db84..c9354d2c5 100644 --- a/src/openhuman/mcp_registry/schemas.rs +++ b/src/openhuman/mcp_registry/schemas.rs @@ -1155,7 +1155,7 @@ fn read_optional_json(params: &Map, key: &str) -> Result(outcome: RpcOutcome) -> Result { - outcome.into_cli_compatible_json() + serde_json::to_value(outcome.value).map_err(|e| e.to_string()) } fn type_name(value: &Value) -> &'static str { diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index c7ec9e867..1f077568a 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -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 ───────────────────────────────