fix(mcp): hide raw registry errors (#4716)

Co-authored-by: Sami Rusani <14844597+samrusani@users.noreply.github.com>
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
Sam
2026-07-22 05:05:23 +03:00
committed by GitHub
co-authored by Sami Rusani Steven Enamakel
parent f18414ff33
commit c5fea23027
24 changed files with 406 additions and 33 deletions
@@ -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(<InstallDialog qualifiedName="unreal-mcp" onSuccess={() => {}} 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(
<InstallDialog qualifiedName="acme/test-server" onSuccess={() => {}} 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 () => {
@@ -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')}
</Button>
</div>
);
@@ -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(<McpCatalogBrowser onSelectInstall={() => {}} />);
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();
});
});
@@ -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 {
@@ -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.
@@ -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<string | null>(null);
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | 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 = () => {
<div
data-testid="mcp-catalog-error"
className="py-8 text-center text-sm text-coral-700 dark:text-coral-300 space-y-2">
<p>{t('mcp.catalog.loadFailed')}</p>
<p>{catalogError}</p>
<Button
variant="tertiary"
size="xs"
@@ -0,0 +1,11 @@
import { getMcpRegistryErrorKind } from '../../../services/api/mcpRegistryErrors';
type Translate = (key: string, fallback?: string) => string;
export function mcpRegistryErrorMessage(error: unknown, t: Translate, fallbackKey: string): string {
const kind = getMcpRegistryErrorKind(error);
if (kind === 'not_found') return t('mcp.registry.error.notFound');
if (kind === 'network') return t('mcp.registry.error.network');
if (kind === 'unavailable') return t('mcp.registry.error.unavailable');
return t(fallbackKey);
}
+6
View File
@@ -1814,6 +1814,12 @@ const messages: TranslationMap = {
'mcp.catalog.searchAria': 'البحث في كتالوج الحدادة',
'mcp.catalog.searchPlaceholder': 'البحث في كتالوج الحدادة...',
'mcp.catalog.loadFailed': 'فشل تحميل الكتالوج',
'mcp.registry.error.notFound':
'لم يتم العثور على الخادم في السجل. تحقق من اسم الخادم وحاول مرة أخرى، أو تصفح خوادم MCP المتاحة، أو أضف الخادم يدويا عبر URL.',
'mcp.registry.error.network':
'تعذر الوصول إلى سجل MCP. تحقق من اتصالك وحاول مرة أخرى، أو أضف الخادم يدويا عبر URL.',
'mcp.registry.error.unavailable':
'سجل MCP غير متاح حاليا. حاول لاحقا، أو تصفح خوادم MCP المتاحة، أو أضف الخادم يدويا عبر URL.',
'mcp.catalog.noResults': 'لم يتم العثور على خوادم.',
'mcp.catalog.noResultsFor': 'لم يتم العثور على خوادم لـ "{query}".',
'mcp.catalog.loadMore': 'تحميل المزيد',
+6
View File
@@ -1851,6 +1851,12 @@ const messages: TranslationMap = {
'mcp.catalog.searchAria': 'MCP সার্ভার ক্যাটালগ অনুসন্ধান',
'mcp.catalog.searchPlaceholder': 'MCP সার্ভার অনুসন্ধান করুন...',
'mcp.catalog.loadFailed': 'ক্যাটালগ করতে ব্যর্থ হয়েছে',
'mcp.registry.error.notFound':
'রেজিস্ট্রিতে সার্ভারটি পাওয়া যায়নি। সার্ভারের নাম পরীক্ষা করে আবার চেষ্টা করুন, উপলব্ধ MCP সার্ভার ব্রাউজ করুন, অথবা URL দিয়ে সার্ভারটি ম্যানুয়ালি যোগ করুন।',
'mcp.registry.error.network':
'MCP রেজিস্ট্রিতে পৌঁছানো যায়নি। আপনার সংযোগ পরীক্ষা করে আবার চেষ্টা করুন, অথবা URL দিয়ে সার্ভারটি ম্যানুয়ালি যোগ করুন।',
'mcp.registry.error.unavailable':
'MCP রেজিস্ট্রি এখন উপলব্ধ নয়। পরে আবার চেষ্টা করুন, উপলব্ধ MCP সার্ভার ব্রাউজ করুন, অথবা URL দিয়ে সার্ভারটি ম্যানুয়ালি যোগ করুন।',
'mcp.catalog.noResults': 'কোনো সার্ভার পাওয়া যায়নি।',
'mcp.catalog.noResultsFor': '"{query}" এর জন্য কোনো সার্ভার পাওয়া যায়নি।',
'mcp.catalog.loadMore': 'আরও লোড করুন',
+6
View File
@@ -1916,6 +1916,12 @@ const messages: TranslationMap = {
'mcp.catalog.searchAria': 'MCP-Server-Katalog durchsuchen',
'mcp.catalog.searchPlaceholder': 'MCP-Server-Katalog durchsuchen...',
'mcp.catalog.loadFailed': 'Katalog konnte nicht geladen werden',
'mcp.registry.error.notFound':
'Server in der Registrierung nicht gefunden. Prüfen Sie den Servernamen und versuchen Sie es erneut, durchsuchen Sie verfügbare MCP-Server oder fügen Sie den Server manuell per URL hinzu.',
'mcp.registry.error.network':
'Die MCP-Registrierung ist nicht erreichbar. Prüfen Sie Ihre Verbindung und versuchen Sie es erneut, oder fügen Sie den Server manuell per URL hinzu.',
'mcp.registry.error.unavailable':
'Die MCP-Registrierung ist derzeit nicht verfügbar. Versuchen Sie es später erneut, durchsuchen Sie verfügbare MCP-Server oder fügen Sie den Server manuell per URL hinzu.',
'mcp.catalog.noResults': 'Keine Server gefunden.',
'mcp.catalog.noResultsFor': 'Keine Server für „{query}“ gefunden.',
'mcp.catalog.loadMore': 'Mehr laden',
+6
View File
@@ -2078,6 +2078,12 @@ const en: TranslationMap = {
'mcp.catalog.searchAria': 'Search MCP server catalog',
'mcp.catalog.searchPlaceholder': 'Search MCP servers...',
'mcp.catalog.loadFailed': 'Failed to load catalog',
'mcp.registry.error.notFound':
'Server not found in registry. Check the server name and try again, browse available MCP servers, or add the server manually by URL.',
'mcp.registry.error.network':
'Could not reach the MCP registry. Check your connection and try again, or add the server manually by URL.',
'mcp.registry.error.unavailable':
'The MCP registry is unavailable right now. Try again later, browse available MCP servers, or add the server manually by URL.',
'mcp.catalog.noResults': 'No servers found.',
'mcp.catalog.noResultsFor': 'No servers found for "{query}".',
'mcp.catalog.loadMore': 'Load more',
+6
View File
@@ -1891,6 +1891,12 @@ const messages: TranslationMap = {
'mcp.catalog.searchAria': 'Buscar catálogo de herrería',
'mcp.catalog.searchPlaceholder': 'Buscar en el catálogo de Herrería...',
'mcp.catalog.loadFailed': 'No se pudo cargar el catálogo',
'mcp.registry.error.notFound':
'No se encontró el servidor en el registro. Revisa el nombre e inténtalo de nuevo, explora los servidores MCP disponibles o añade el servidor manualmente por URL.',
'mcp.registry.error.network':
'No se pudo conectar con el registro MCP. Revisa tu conexión e inténtalo de nuevo, o añade el servidor manualmente por URL.',
'mcp.registry.error.unavailable':
'El registro MCP no está disponible ahora. Inténtalo más tarde, explora los servidores MCP disponibles o añade el servidor manualmente por URL.',
'mcp.catalog.noResults': 'No se encontraron servidores.',
'mcp.catalog.noResultsFor': 'No se encontraron servidores para "{query}".',
'mcp.catalog.loadMore': 'Cargar más',
+6
View File
@@ -1910,6 +1910,12 @@ const messages: TranslationMap = {
'mcp.catalog.searchAria': 'Rechercher des serveurs MCP',
'mcp.catalog.searchPlaceholder': 'Rechercher des serveurs MCP...',
'mcp.catalog.loadFailed': 'Échec du chargement du catalogue',
'mcp.registry.error.notFound':
'Serveur introuvable dans le registre. Vérifiez le nom du serveur puis réessayez, parcourez les serveurs MCP disponibles ou ajoutez le serveur manuellement par URL.',
'mcp.registry.error.network':
'Impossible de joindre le registre MCP. Vérifiez votre connexion puis réessayez, ou ajoutez le serveur manuellement par URL.',
'mcp.registry.error.unavailable':
'Le registre MCP est indisponible pour le moment. Réessayez plus tard, parcourez les serveurs MCP disponibles ou ajoutez le serveur manuellement par URL.',
'mcp.catalog.noResults': 'Aucun serveur trouvé.',
'mcp.catalog.noResultsFor': 'Aucun serveur trouvé pour "{query}".',
'mcp.catalog.loadMore': 'Charger plus',
+6
View File
@@ -1849,6 +1849,12 @@ const messages: TranslationMap = {
'mcp.catalog.searchAria': 'स्मिथेरी कैटलॉग खोजें',
'mcp.catalog.searchPlaceholder': 'स्मिथरी कैटलॉग खोजें...',
'mcp.catalog.loadFailed': 'कैटलॉग लोड करने में विफल',
'mcp.registry.error.notFound':
'रजिस्ट्री में सर्वर नहीं मिला। सर्वर का नाम जांचकर फिर कोशिश करें, उपलब्ध MCP सर्वर ब्राउज करें, या URL से सर्वर को मैन्युअल रूप से जोड़ें।',
'mcp.registry.error.network':
'MCP रजिस्ट्री तक नहीं पहुंचा जा सका। अपना कनेक्शन जांचकर फिर कोशिश करें, या URL से सर्वर को मैन्युअल रूप से जोड़ें।',
'mcp.registry.error.unavailable':
'MCP रजिस्ट्री अभी उपलब्ध नहीं है। बाद में फिर कोशिश करें, उपलब्ध MCP सर्वर ब्राउज करें, या URL से सर्वर को मैन्युअल रूप से जोड़ें।',
'mcp.catalog.noResults': 'कोई सर्वर नहीं मिला.',
'mcp.catalog.noResultsFor': '"{query}" के लिए कोई सर्वर नहीं मिला।',
'mcp.catalog.loadMore': 'और अधिक लोड करें',
+6
View File
@@ -1867,6 +1867,12 @@ const messages: TranslationMap = {
'mcp.catalog.searchAria': 'Cari katalog server MCP',
'mcp.catalog.searchPlaceholder': 'Cari katalog server MCP...',
'mcp.catalog.loadFailed': 'Gagal memuat katalog',
'mcp.registry.error.notFound':
'Server tidak ditemukan di registri. Periksa nama server lalu coba lagi, jelajahi server MCP yang tersedia, atau tambahkan server secara manual lewat URL.',
'mcp.registry.error.network':
'Tidak dapat menjangkau registri MCP. Periksa koneksi Anda lalu coba lagi, atau tambahkan server secara manual lewat URL.',
'mcp.registry.error.unavailable':
'Registri MCP sedang tidak tersedia. Coba lagi nanti, jelajahi server MCP yang tersedia, atau tambahkan server secara manual lewat URL.',
'mcp.catalog.noResults': 'Tidak ada server yang ditemukan.',
'mcp.catalog.noResultsFor': 'Tidak ditemukan server untuk "{query}".',
'mcp.catalog.loadMore': 'Muat selengkapnya',
+6
View File
@@ -1895,6 +1895,12 @@ const messages: TranslationMap = {
'mcp.catalog.searchAria': 'Cerca nel catalogo della fucina',
'mcp.catalog.searchPlaceholder': 'Cerca nel catalogo della fucina...',
'mcp.catalog.loadFailed': 'Impossibile caricare il catalogo',
'mcp.registry.error.notFound':
'Server non trovato nel registro. Controlla il nome del server e riprova, sfoglia i server MCP disponibili o aggiungi il server manualmente tramite URL.',
'mcp.registry.error.network':
'Impossibile raggiungere il registro MCP. Controlla la connessione e riprova, oppure aggiungi il server manualmente tramite URL.',
'mcp.registry.error.unavailable':
'Il registro MCP non è disponibile al momento. Riprova più tardi, sfoglia i server MCP disponibili o aggiungi il server manualmente tramite URL.',
'mcp.catalog.noResults': 'Nessun server trovato.',
'mcp.catalog.noResultsFor': 'Nessun server trovato per "{query}".',
'mcp.catalog.loadMore': 'Carica altro',
+6
View File
@@ -1840,6 +1840,12 @@ const messages: TranslationMap = {
'mcp.catalog.searchAria': 'MCP 서버 카탈로그 검색',
'mcp.catalog.searchPlaceholder': 'MCP 서버 카탈로그 검색...',
'mcp.catalog.loadFailed': '카탈로그를 로드하지 못했습니다.',
'mcp.registry.error.notFound':
'레지스트리에서 서버를 찾을 수 없습니다. 서버 이름을 확인한 뒤 다시 시도하거나, 사용 가능한 MCP 서버를 찾아보거나, URL로 서버를 직접 추가하세요.',
'mcp.registry.error.network':
'MCP 레지스트리에 연결할 수 없습니다. 연결을 확인한 뒤 다시 시도하거나, URL로 서버를 직접 추가하세요.',
'mcp.registry.error.unavailable':
'현재 MCP 레지스트리를 사용할 수 없습니다. 나중에 다시 시도하거나, 사용 가능한 MCP 서버를 찾아보거나, URL로 서버를 직접 추가하세요.',
'mcp.catalog.noResults': '서버를 찾을 수 없습니다.',
'mcp.catalog.noResultsFor': '"{query}"에 대한 서버를 찾을 수 없습니다.',
'mcp.catalog.loadMore': '추가 로드',
+6
View File
@@ -1882,6 +1882,12 @@ const messages: TranslationMap = {
'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.registry.error.notFound':
'Nie znaleziono serwera w rejestrze. Sprawdź nazwę serwera i spróbuj ponownie, przeglądaj dostępne serwery MCP albo dodaj serwer ręcznie przez URL.',
'mcp.registry.error.network':
'Nie można połączyć się z rejestrem MCP. Sprawdź połączenie i spróbuj ponownie albo dodaj serwer ręcznie przez URL.',
'mcp.registry.error.unavailable':
'Rejestr MCP jest teraz niedostępny. Spróbuj później, przeglądaj dostępne serwery MCP albo dodaj serwer ręcznie przez URL.',
'mcp.catalog.noResults': 'Nie znaleziono serwerów.',
'mcp.catalog.noResultsFor': 'Nie znaleziono serwerów dla „{query}”.',
'mcp.catalog.loadMore': 'Załaduj więcej',
+6
View File
@@ -1891,6 +1891,12 @@ const messages: TranslationMap = {
'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.registry.error.notFound':
'Servidor não encontrado no registro. Confira o nome do servidor e tente novamente, navegue pelos servidores MCP disponíveis ou adicione o servidor manualmente por URL.',
'mcp.registry.error.network':
'Não foi possível acessar o registro MCP. Verifique sua conexão e tente novamente, ou adicione o servidor manualmente por URL.',
'mcp.registry.error.unavailable':
'O registro MCP está indisponível no momento. Tente novamente mais tarde, navegue pelos servidores MCP disponíveis ou adicione o servidor manualmente por URL.',
'mcp.catalog.noResults': 'Nenhum servidor encontrado.',
'mcp.catalog.noResultsFor': 'Nenhum servidor encontrado para "{query}".',
'mcp.catalog.loadMore': 'Carregar mais',
+6
View File
@@ -1874,6 +1874,12 @@ const messages: TranslationMap = {
'mcp.catalog.searchAria': 'Поиск в каталоге кузнечного дела',
'mcp.catalog.searchPlaceholder': 'Поиск в каталоге кузнечного дела...',
'mcp.catalog.loadFailed': 'Не удалось загрузить каталог.',
'mcp.registry.error.notFound':
'Сервер не найден в реестре. Проверьте имя сервера и повторите попытку, просмотрите доступные MCP-серверы или добавьте сервер вручную по URL.',
'mcp.registry.error.network':
'Не удалось подключиться к реестру MCP. Проверьте соединение и повторите попытку или добавьте сервер вручную по URL.',
'mcp.registry.error.unavailable':
'Реестр MCP сейчас недоступен. Повторите попытку позже, просмотрите доступные MCP-серверы или добавьте сервер вручную по URL.',
'mcp.catalog.noResults': 'Серверы не найдены.',
'mcp.catalog.noResultsFor': 'Серверы для «{query}» не найдены.',
'mcp.catalog.loadMore': 'Загрузить больше',
+6
View File
@@ -1753,6 +1753,12 @@ const messages: TranslationMap = {
'mcp.catalog.searchAria': '搜索锻造目录',
'mcp.catalog.searchPlaceholder': '搜索锻造目录...',
'mcp.catalog.loadFailed': '加载目录失败',
'mcp.registry.error.notFound':
'在注册表中找不到该服务器。请检查服务器名称后重试,也可以浏览可用的 MCP 服务器,或通过 URL 手动添加服务器。',
'mcp.registry.error.network':
'无法连接到 MCP 注册表。请检查网络连接后重试,或通过 URL 手动添加服务器。',
'mcp.registry.error.unavailable':
'MCP 注册表暂时不可用。请稍后重试,也可以浏览可用的 MCP 服务器,或通过 URL 手动添加服务器。',
'mcp.catalog.noResults': '未找到服务器。',
'mcp.catalog.noResultsFor': '找不到“{query}”的服务器。',
'mcp.catalog.loadMore': '加载更多',
+111
View File
@@ -1,5 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { classifyMcpRegistryError, McpRegistryUserError } from './mcpRegistryErrors';
const mockCallCoreRpc = vi.fn();
vi.mock('../coreRpcClient', () => ({
@@ -11,6 +13,26 @@ describe('mcpClientsApi', () => {
mockCallCoreRpc.mockReset();
});
describe('registry error classification', () => {
it('classifies official empty-version misses as not found', () => {
expect(
classifyMcpRegistryError(
new Error('Failed to fetch registry detail: no versions found for unreal-mcp')
)
).toBe('not_found');
});
it('lets 5xx status codes win over not-found body text', () => {
expect(
classifyMcpRegistryError(
new Error(
'MCP official registry GET unreal-mcp returned HTTP 500: {"detail":"Server not found"}'
)
)
).toBe('unavailable');
});
});
describe('registrySearch', () => {
it('calls the correct method and returns servers', async () => {
const servers = [{ qualified_name: 'test/server', display_name: 'Test' }];
@@ -39,6 +61,34 @@ describe('mcpClientsApi', () => {
params: {},
});
});
it('normalizes raw registry search outages before they reach the UI', async () => {
mockCallCoreRpc.mockRejectedValueOnce(
new Error('MCP official registry returned HTTP 500: {"detail":"upstream exploded"}')
);
const { mcpClientsApi } = await import('./mcpClientsApi');
try {
await mcpClientsApi.registrySearch({ query: 'github' });
throw new Error('expected registrySearch to reject');
} catch (err) {
expect(err).toBeInstanceOf(McpRegistryUserError);
expect(err).toMatchObject({ kind: 'unavailable' });
expect((err as Error).message).toContain('The MCP registry is unavailable right now');
expect((err as Error).message).not.toContain('{"detail"');
}
});
it('normalizes registry transport failures to network guidance', async () => {
mockCallCoreRpc.mockRejectedValueOnce(new Error('Failed to fetch'));
const { mcpClientsApi } = await import('./mcpClientsApi');
await expect(mcpClientsApi.registrySearch({ query: 'github' })).rejects.toMatchObject({
kind: 'network',
});
});
});
describe('registryGet', () => {
@@ -60,6 +110,27 @@ describe('mcpClientsApi', () => {
});
expect(result).toEqual(serverDetail);
});
it('normalizes raw registry 404 JSON before detail errors reach the UI', async () => {
mockCallCoreRpc.mockRejectedValueOnce(
new Error(
'MCP official registry GET unreal-mcp returned HTTP 404 Not Found: {"title":"Not Found","status":404,"detail":"Server not found"}'
)
);
const { mcpClientsApi } = await import('./mcpClientsApi');
try {
await mcpClientsApi.registryGet('unreal-mcp');
throw new Error('expected registryGet to reject');
} catch (err) {
expect(err).toBeInstanceOf(McpRegistryUserError);
expect(err).toMatchObject({ kind: 'not_found' });
expect((err as Error).message).toContain('Server not found in registry');
expect((err as Error).message).not.toContain('"title"');
expect((err as McpRegistryUserError).rawMessage).toContain('HTTP 404');
}
});
});
describe('installedList', () => {
@@ -154,6 +225,46 @@ describe('mcpClientsApi', () => {
});
expect(result).toEqual(server);
});
it('normalizes registry detail fetch failures during install', async () => {
mockCallCoreRpc.mockRejectedValueOnce(
new Error(
'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"}'
)
);
const { mcpClientsApi } = await import('./mcpClientsApi');
try {
await mcpClientsApi.install({ qualified_name: 'unreal-mcp', env: {} });
throw new Error('expected install to reject');
} catch (err) {
expect(err).toBeInstanceOf(McpRegistryUserError);
expect(err).toMatchObject({ kind: 'not_found' });
expect((err as Error).message).toContain('Server not found in registry');
expect((err as Error).message).not.toContain('"title"');
}
});
it('preserves non-registry install failures', async () => {
mockCallCoreRpc.mockRejectedValueOnce(new Error('spawn failed'));
const { mcpClientsApi } = await import('./mcpClientsApi');
await expect(
mcpClientsApi.install({ qualified_name: 'test/server', env: {} })
).rejects.toThrow('spawn failed');
});
it('preserves non-registry HTTP failures during install', async () => {
mockCallCoreRpc.mockRejectedValueOnce(new Error('installer returned HTTP 404'));
const { mcpClientsApi } = await import('./mcpClientsApi');
await expect(
mcpClientsApi.install({ qualified_name: 'test/server', env: {} })
).rejects.toThrow('installer returned HTTP 404');
});
});
describe('uninstall', () => {
+38 -18
View File
@@ -15,6 +15,7 @@ import type {
SmitheryServerDetail,
} from '../../components/channels/mcp/types';
import { callCoreRpc } from '../coreRpcClient';
import { isMcpRegistryErrorLike, normalizeMcpRegistryError } from './mcpRegistryErrors';
const log = debug('mcp-clients:api');
@@ -113,23 +114,35 @@ export const mcpClientsApi = {
page_size?: number;
}): Promise<RegistrySearchResult> => {
log('registry_search params=%o', params);
const result = await callCoreRpc<RegistrySearchResult>({
method: 'openhuman.mcp_clients_registry_search',
params,
});
log('registry_search result: %d servers', result.servers?.length ?? 0);
return result;
try {
const result = await callCoreRpc<RegistrySearchResult>({
method: 'openhuman.mcp_clients_registry_search',
params,
});
log('registry_search result: %d servers', result.servers?.length ?? 0);
return result;
} catch (err) {
const normalized = normalizeMcpRegistryError(err);
log('registry_search error kind=%s', normalized.kind);
throw normalized;
}
},
/** Fetch full detail for a single Smithery server. */
registryGet: async (qualified_name: string): Promise<SmitheryServerDetail> => {
log('registry_get qualified_name=%s', qualified_name);
const result = await callCoreRpc<RegistryGetResult>({
method: 'openhuman.mcp_clients_registry_get',
params: { qualified_name },
});
log('registry_get returned server=%s', result.server?.qualified_name);
return result.server;
try {
const result = await callCoreRpc<RegistryGetResult>({
method: 'openhuman.mcp_clients_registry_get',
params: { qualified_name },
});
log('registry_get returned server=%s', result.server?.qualified_name);
return result.server;
} catch (err) {
const normalized = normalizeMcpRegistryError(err);
log('registry_get error kind=%s', normalized.kind);
throw normalized;
}
},
/**
@@ -196,12 +209,19 @@ export const mcpClientsApi = {
config?: unknown;
}): Promise<InstalledServer> => {
log('install qualified_name=%s', params.qualified_name);
const result = await callCoreRpc<InstallResult>({
method: 'openhuman.mcp_clients_install',
params,
});
log('install returned server_id=%s', result.server?.server_id);
return result.server;
try {
const result = await callCoreRpc<InstallResult>({
method: 'openhuman.mcp_clients_install',
params,
});
log('install returned server_id=%s', result.server?.server_id);
return result.server;
} catch (err) {
if (!isMcpRegistryErrorLike(err)) throw err;
const normalized = normalizeMcpRegistryError(err);
log('install registry error kind=%s', normalized.kind);
throw normalized;
}
},
/**
+92
View File
@@ -0,0 +1,92 @@
export type McpRegistryErrorKind = 'not_found' | 'network' | 'unavailable';
const FALLBACK_MESSAGES: Record<McpRegistryErrorKind, string> = {
not_found:
'Server not found in registry. Check the server name and try again, browse available MCP servers, or add the server manually by URL.',
network:
'Could not reach the MCP registry. Check your connection and try again, or add the server manually by URL.',
unavailable:
'The MCP registry is unavailable right now. Try again later, browse available MCP servers, or add the server manually by URL.',
};
export class McpRegistryUserError extends Error {
readonly kind: McpRegistryErrorKind;
readonly rawMessage: string;
constructor(kind: McpRegistryErrorKind, rawMessage: string) {
super(FALLBACK_MESSAGES[kind]);
this.name = 'McpRegistryUserError';
this.kind = kind;
this.rawMessage = rawMessage;
}
}
function rawErrorMessage(error: unknown): string {
if (error instanceof Error && error.message) return error.message;
if (typeof error === 'string') return error;
if (error && typeof error === 'object') {
const maybeMessage = (error as { message?: unknown }).message;
if (typeof maybeMessage === 'string' && maybeMessage.trim()) return maybeMessage;
}
return 'Unknown MCP registry error';
}
function httpStatusCodes(message: string): number[] {
const codes: number[] = [];
for (const match of message.matchAll(/\bHTTP\s+(\d{3})\b/gi)) {
codes.push(Number(match[1]));
}
return codes;
}
function embeddedStatusCodes(message: string): number[] {
const codes: number[] = [];
for (const match of message.matchAll(/["']?status["']?\s*:\s*(\d{3})/gi)) {
codes.push(Number(match[1]));
}
return codes;
}
export function isMcpRegistryErrorLike(error: unknown): boolean {
if (error instanceof McpRegistryUserError) return true;
const message = rawErrorMessage(error);
return /MCP .*registry|registry detail|registry .*failed|all MCP registries failed|MCP official|Smithery|no versions found for/i.test(
message
);
}
export function classifyMcpRegistryError(error: unknown): McpRegistryErrorKind {
if (error instanceof McpRegistryUserError) return error.kind;
const message = rawErrorMessage(error);
const httpCodes = httpStatusCodes(message);
const embeddedCodes = embeddedStatusCodes(message);
if (httpCodes.includes(404)) return 'not_found';
if (httpCodes.some(code => code === 429 || code >= 500)) return 'unavailable';
if (embeddedCodes.includes(404)) return 'not_found';
if (embeddedCodes.some(code => code === 429 || code >= 500)) return 'unavailable';
if (/server not found|no versions found for/i.test(message)) return 'not_found';
if (
/timed?\s*out|timeout|abort|failed to fetch|networkerror|econnrefused|enotfound|error sending request|client error \(connect\)|request failed|read failed|failed to respond|all MCP registries failed/i.test(
message
)
) {
return 'network';
}
if (/returned HTTP|registry .*failed|HTTP\s+\d{3}/i.test(message)) return 'unavailable';
return 'unavailable';
}
export function normalizeMcpRegistryError(error: unknown): McpRegistryUserError {
if (error instanceof McpRegistryUserError) return error;
return new McpRegistryUserError(classifyMcpRegistryError(error), rawErrorMessage(error));
}
export function getMcpRegistryErrorKind(error: unknown): McpRegistryErrorKind | null {
if (!error) return null;
if (!isMcpRegistryErrorLike(error)) return null;
return classifyMcpRegistryError(error);
}