diff --git a/app/src/components/channels/mcp/InstallDialog.test.tsx b/app/src/components/channels/mcp/InstallDialog.test.tsx index 1998ad107..30c3eb956 100644 --- a/app/src/components/channels/mcp/InstallDialog.test.tsx +++ b/app/src/components/channels/mcp/InstallDialog.test.tsx @@ -1,6 +1,7 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { McpRegistryUserError } from '../../../services/api/mcpRegistryErrors'; import InstallDialog from './InstallDialog'; const mockRegistryGet = vi.fn(); @@ -98,6 +99,22 @@ describe('InstallDialog', () => { expect(screen.getByText('SECRET_TOKEN')).toBeInTheDocument(); }); + it('shows friendly guidance instead of raw registry 404 JSON when detail load fails', async () => { + mockRegistryGet.mockRejectedValue( + new Error( + 'MCP official registry GET unreal-mcp returned HTTP 404 Not Found: {"title":"Not Found","status":404,"detail":"Server not found"}' + ) + ); + render( {}} onCancel={() => {}} />); + + await waitFor(() => screen.getByText(/Server not found in registry/)); + + expect(screen.getByText(/browse available MCP servers/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Browse catalog' })).toBeInTheDocument(); + expect(screen.queryByText(/"title":"Not Found"/)).not.toBeInTheDocument(); + expect(screen.queryByText(/HTTP 404/)).not.toBeInTheDocument(); + }); + it('renders env key inputs after clicking configure', async () => { mockRegistryGet.mockResolvedValue(DETAIL); render( @@ -232,7 +249,34 @@ describe('InstallDialog', () => { fireEvent.click(screen.getByRole('button', { name: 'Install' })); }); - await waitFor(() => screen.getByText('Server error')); + await waitFor(() => screen.getByText('Install failed')); + }); + + it('shows friendly guidance instead of raw registry JSON when install re-fetch fails', async () => { + mockRegistryGet.mockResolvedValue(DETAIL); + mockInstall.mockRejectedValue( + new McpRegistryUserError( + 'not_found', + 'Failed to fetch registry detail: MCP official registry GET unreal-mcp returned HTTP 404 Not Found: {"title":"Not Found","status":404,"detail":"Server not found"}' + ) + ); + + render( + {}} onCancel={() => {}} /> + ); + + await goToConfigureStep(); + fireEvent.change(screen.getByLabelText('API_KEY'), { target: { value: 'key' } }); + fireEvent.change(screen.getByLabelText('SECRET_TOKEN'), { target: { value: 'secret' } }); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Install' })); + }); + + await waitFor(() => screen.getByText(/Server not found in registry/)); + + expect(screen.queryByText(/"title":"Not Found"/)).not.toBeInTheDocument(); + expect(screen.queryByText(/HTTP 404/)).not.toBeInTheDocument(); }); it('calls onCancel when Cancel is clicked on detail step', async () => { diff --git a/app/src/components/channels/mcp/InstallDialog.tsx b/app/src/components/channels/mcp/InstallDialog.tsx index 8be4f7474..9bfd1559b 100644 --- a/app/src/components/channels/mcp/InstallDialog.tsx +++ b/app/src/components/channels/mcp/InstallDialog.tsx @@ -16,6 +16,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useT } from '../../../lib/i18n/I18nContext'; import { mcpClientsApi } from '../../../services/api/mcpClientsApi'; import Button from '../../ui/Button'; +import { mcpRegistryErrorMessage } from './mcpRegistryErrorMessage'; import { deriveAuthor } from './McpServerCard'; import type { InstalledServer, SmitheryServerDetail } from './types'; @@ -74,7 +75,7 @@ const InstallDialog = ({ qualifiedName, prefillEnv, onSuccess, onCancel }: Insta }) .catch(err => { if (latestQualifiedNameRef.current !== requestedName) return; - const msg = err instanceof Error ? err.message : t('mcp.install.failedDetail'); + const msg = mcpRegistryErrorMessage(err, t, 'mcp.install.failedDetail'); log('detail error: %s', msg); setDetailError(msg); }) @@ -144,7 +145,7 @@ const InstallDialog = ({ qualifiedName, prefillEnv, onSuccess, onCancel }: Insta } onSuccess(server); } catch (err) { - const msg = err instanceof Error ? err.message : t('mcp.install.failedInstall'); + const msg = mcpRegistryErrorMessage(err, t, 'mcp.install.failedInstall'); log('install error: %s', msg); setInstallError(msg); } finally { @@ -182,7 +183,7 @@ const InstallDialog = ({ qualifiedName, prefillEnv, onSuccess, onCancel }: Insta size="sm" onClick={onCancel} className="text-content-muted hover:underline"> - {t('mcp.install.back')} + {t('mcp.installed.browseCatalog')} ); diff --git a/app/src/components/channels/mcp/McpCatalogBrowser.test.tsx b/app/src/components/channels/mcp/McpCatalogBrowser.test.tsx index b1ec18a65..26b349ce8 100644 --- a/app/src/components/channels/mcp/McpCatalogBrowser.test.tsx +++ b/app/src/components/channels/mcp/McpCatalogBrowser.test.tsx @@ -143,8 +143,10 @@ describe('McpCatalogBrowser', () => { expect(screen.queryByRole('button', { name: 'Install' })).not.toBeInTheDocument(); }); - it('shows error state when search fails', async () => { - mockRegistrySearch.mockRejectedValue(new Error('Network error')); + it('shows friendly guidance when search fails', async () => { + mockRegistrySearch.mockRejectedValue( + new Error('MCP official registry returned HTTP 500: {"detail":"upstream down"}') + ); render( {}} />); await act(async () => { @@ -152,6 +154,8 @@ describe('McpCatalogBrowser', () => { }); vi.useRealTimers(); - await waitFor(() => screen.getByText('Network error')); + await waitFor(() => screen.getByText(/The MCP registry is unavailable right now/)); + expect(screen.getByText(/browse available MCP servers/)).toBeInTheDocument(); + expect(screen.queryByText(/"detail":"upstream down"/)).not.toBeInTheDocument(); }); }); diff --git a/app/src/components/channels/mcp/McpCatalogBrowser.tsx b/app/src/components/channels/mcp/McpCatalogBrowser.tsx index 9b669ff87..a8722919b 100644 --- a/app/src/components/channels/mcp/McpCatalogBrowser.tsx +++ b/app/src/components/channels/mcp/McpCatalogBrowser.tsx @@ -8,6 +8,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useT } from '../../../lib/i18n/I18nContext'; import { mcpClientsApi } from '../../../services/api/mcpClientsApi'; import Button from '../../ui/Button'; +import { mcpRegistryErrorMessage } from './mcpRegistryErrorMessage'; import McpServerCard from './McpServerCard'; import type { SmitheryServer } from './types'; @@ -57,7 +58,7 @@ const McpCatalogBrowser = ({ onSelectInstall }: McpCatalogBrowserProps) => { log('loaded %d servers (append=%s)', incoming.length, append); } catch (err) { if (seq !== requestSeqRef.current) return; - const msg = err instanceof Error ? err.message : t('mcp.catalog.loadFailed'); + const msg = mcpRegistryErrorMessage(err, t, 'mcp.catalog.loadFailed'); log('catalog fetch error: %s', msg); setError(msg); } finally { diff --git a/app/src/components/channels/mcp/McpServersTab.test.tsx b/app/src/components/channels/mcp/McpServersTab.test.tsx index 55dac4aa6..4d59e7595 100644 --- a/app/src/components/channels/mcp/McpServersTab.test.tsx +++ b/app/src/components/channels/mcp/McpServersTab.test.tsx @@ -520,7 +520,9 @@ describe('McpServersTab', () => { mockInstalledList.mockResolvedValue([]); mockStatus.mockResolvedValue([]); // First catalog fetch fails → error state; retry succeeds → rows render. - mockRegistrySearch.mockRejectedValueOnce(new Error('registry down')); + mockRegistrySearch.mockRejectedValueOnce( + new Error('MCP official registry returned HTTP 500: {"detail":"registry down"}') + ); mockRegistrySearch.mockResolvedValue({ servers: [{ qualified_name: 'a/srv', display_name: 'Recovered Srv', is_deployed: false }], page: 1, @@ -532,7 +534,9 @@ describe('McpServersTab', () => { // Error surfaces instead of a silent empty state. const errorBox = await screen.findByTestId('mcp-catalog-error'); - expect(errorBox).toHaveTextContent('Failed to load catalog'); + expect(errorBox).toHaveTextContent('The MCP registry is unavailable right now'); + expect(errorBox).toHaveTextContent('browse available MCP servers'); + expect(errorBox).not.toHaveTextContent('"detail":"registry down"'); expect(screen.queryByTestId('mcp-catalog-empty')).not.toBeInTheDocument(); // Retry re-fetches and renders the recovered catalog. diff --git a/app/src/components/channels/mcp/McpServersTab.tsx b/app/src/components/channels/mcp/McpServersTab.tsx index ca4e36596..428560dc9 100644 --- a/app/src/components/channels/mcp/McpServersTab.tsx +++ b/app/src/components/channels/mcp/McpServersTab.tsx @@ -17,6 +17,7 @@ import InstallDialog from './InstallDialog'; import InstalledServerDetail from './InstalledServerDetail'; import McpConnectionHealthToolbar from './McpConnectionHealthToolbar'; import McpInventoryPanel from './McpInventoryPanel'; +import { mcpRegistryErrorMessage } from './mcpRegistryErrorMessage'; import { deriveAuthor } from './McpServerCard'; import type { ConnStatus, InstalledServer, ServerStatus, SmitheryServer } from './types'; @@ -272,7 +273,7 @@ const McpServersTab = () => { const [catalogTotalPages, setCatalogTotalPages] = useState(1); // Set when a registry fetch fails so the Registry view shows an error state // (with retry) instead of silently falling back to an empty/stale catalog. - const [catalogError, setCatalogError] = useState(false); + const [catalogError, setCatalogError] = useState(null); const pollTimerRef = useRef | null>(null); const debounceRef = useRef | null>(null); @@ -317,18 +318,18 @@ const McpServersTab = () => { ); setCatalogPage(result.page); setCatalogTotalPages(result.total_pages); - setCatalogError(false); + setCatalogError(null); } catch (err) { if (seq !== requestSeqRef.current) return; log('catalog fetch error: %o', err); // A fresh (non-append) fetch that fails leaves no usable rows — surface // the error. A failed "load more" keeps the rows already shown. - if (!append) setCatalogError(true); + if (!append) setCatalogError(mcpRegistryErrorMessage(err, t, 'mcp.catalog.loadFailed')); } finally { if (seq === requestSeqRef.current) setCatalogLoading(false); } }, - [] + [t] ); useEffect(() => { @@ -739,7 +740,7 @@ const McpServersTab = () => {
-

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

+

{catalogError}