mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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:
co-authored by
Sami Rusani
Steven Enamakel
parent
f18414ff33
commit
c5fea23027
@@ -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);
|
||||
}
|
||||
@@ -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': 'تحميل المزيد',
|
||||
|
||||
@@ -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': 'আরও লোড করুন',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'और अधिक लोड करें',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '추가 로드',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Загрузить больше',
|
||||
|
||||
@@ -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': '加载更多',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user