feat(mcp-registry): rich install screen and table UI redesign (#3492)

This commit is contained in:
Steven Enamakel
2026-06-08 07:19:11 -07:00
committed by GitHub
parent c9f942805c
commit d9eb79fd56
25 changed files with 1000 additions and 229 deletions
@@ -19,10 +19,23 @@ const DETAIL = {
qualified_name: 'acme/test-server',
display_name: 'Test Server',
description: 'A test server',
connections: [],
connections: [{ type: 'stdio', published: true }],
required_env_keys: ['API_KEY', 'SECRET_TOKEN'],
};
const DETAIL_NO_ENV = {
qualified_name: 'acme/simple-server',
display_name: 'Simple Server',
description: 'No env needed',
connections: [{ type: 'stdio', published: true }],
required_env_keys: [],
};
async function goToConfigureStep() {
await waitFor(() => screen.getByRole('button', { name: 'Configure & install' }));
fireEvent.click(screen.getByRole('button', { name: 'Configure & install' }));
}
describe('InstallDialog', () => {
beforeEach(() => {
mockRegistryGet.mockReset();
@@ -32,7 +45,6 @@ describe('InstallDialog', () => {
});
it('shows loading state while fetching detail', () => {
// Never resolves within the test
mockRegistryGet.mockReturnValue(new Promise(() => {}));
render(
<InstallDialog qualifiedName="acme/test-server" onSuccess={() => {}} onCancel={() => {}} />
@@ -40,15 +52,38 @@ describe('InstallDialog', () => {
expect(screen.getByText('Loading server details...')).toBeInTheDocument();
});
it('renders env key inputs from registry_get', async () => {
it('renders detail overview with server info', async () => {
mockRegistryGet.mockResolvedValue(DETAIL);
render(
<InstallDialog qualifiedName="acme/test-server" onSuccess={() => {}} onCancel={() => {}} />
);
await waitFor(() => {
expect(screen.getByLabelText('API_KEY')).toBeInTheDocument();
expect(screen.getByText('Test Server')).toBeInTheDocument();
});
expect(screen.getByText('A test server')).toBeInTheDocument();
expect(screen.getByText('Requires configuration')).toBeInTheDocument();
expect(screen.getByText('Runs locally')).toBeInTheDocument();
});
it('shows env key preview badges on detail step', async () => {
mockRegistryGet.mockResolvedValue(DETAIL);
render(
<InstallDialog qualifiedName="acme/test-server" onSuccess={() => {}} onCancel={() => {}} />
);
await waitFor(() => screen.getByText('API_KEY'));
expect(screen.getByText('SECRET_TOKEN')).toBeInTheDocument();
});
it('renders env key inputs after clicking configure', async () => {
mockRegistryGet.mockResolvedValue(DETAIL);
render(
<InstallDialog qualifiedName="acme/test-server" onSuccess={() => {}} onCancel={() => {}} />
);
await goToConfigureStep();
expect(screen.getByLabelText('API_KEY')).toBeInTheDocument();
expect(screen.getByLabelText('SECRET_TOKEN')).toBeInTheDocument();
});
@@ -58,8 +93,7 @@ describe('InstallDialog', () => {
<InstallDialog qualifiedName="acme/test-server" onSuccess={() => {}} onCancel={() => {}} />
);
await waitFor(() => screen.getByLabelText('API_KEY'));
await goToConfigureStep();
const input = screen.getByLabelText('API_KEY') as HTMLInputElement;
expect(input.type).toBe('password');
});
@@ -70,11 +104,9 @@ describe('InstallDialog', () => {
<InstallDialog qualifiedName="acme/test-server" onSuccess={() => {}} onCancel={() => {}} />
);
await waitFor(() => screen.getByLabelText('API_KEY'));
await goToConfigureStep();
const showButtons = screen.getAllByRole('button', { name: 'Show' });
fireEvent.click(showButtons[0]);
const input = screen.getByLabelText('API_KEY') as HTMLInputElement;
expect(input.type).toBe('text');
});
@@ -97,8 +129,7 @@ describe('InstallDialog', () => {
<InstallDialog qualifiedName="acme/test-server" onSuccess={onSuccess} onCancel={() => {}} />
);
await waitFor(() => screen.getByLabelText('API_KEY'));
await goToConfigureStep();
fireEvent.change(screen.getByLabelText('API_KEY'), { target: { value: 'my-api-key' } });
fireEvent.change(screen.getByLabelText('SECRET_TOKEN'), { target: { value: 'my-secret' } });
@@ -111,7 +142,6 @@ describe('InstallDialog', () => {
env: { API_KEY: 'my-api-key', SECRET_TOKEN: 'my-secret' },
config: undefined,
});
// Auto-connect on success (issue #3039 gap B3).
expect(mockConnect).toHaveBeenCalledWith('srv-1');
expect(onSuccess).toHaveBeenCalledWith(installedServer);
});
@@ -135,7 +165,7 @@ describe('InstallDialog', () => {
<InstallDialog qualifiedName="acme/test-server" onSuccess={onSuccess} onCancel={() => {}} />
);
await waitFor(() => screen.getByLabelText('API_KEY'));
await goToConfigureStep();
fireEvent.change(screen.getByLabelText('API_KEY'), { target: { value: 'k' } });
fireEvent.change(screen.getByLabelText('SECRET_TOKEN'), { target: { value: 's' } });
@@ -144,7 +174,6 @@ describe('InstallDialog', () => {
});
expect(mockConnect).toHaveBeenCalledWith('srv-1');
// A connect failure must NOT block the install success callback.
expect(onSuccess).toHaveBeenCalledWith(installedServer);
});
@@ -154,9 +183,7 @@ describe('InstallDialog', () => {
<InstallDialog qualifiedName="acme/test-server" onSuccess={() => {}} onCancel={() => {}} />
);
await waitFor(() => screen.getByLabelText('API_KEY'));
// Leave API_KEY empty, fill only SECRET_TOKEN
await goToConfigureStep();
fireEvent.change(screen.getByLabelText('SECRET_TOKEN'), { target: { value: 'secret' } });
await act(async () => {
@@ -175,8 +202,7 @@ describe('InstallDialog', () => {
<InstallDialog qualifiedName="acme/test-server" onSuccess={() => {}} onCancel={() => {}} />
);
await waitFor(() => screen.getByLabelText('API_KEY'));
await goToConfigureStep();
fireEvent.change(screen.getByLabelText('API_KEY'), { target: { value: 'key' } });
fireEvent.change(screen.getByLabelText('SECRET_TOKEN'), { target: { value: 'secret' } });
@@ -187,7 +213,7 @@ describe('InstallDialog', () => {
await waitFor(() => screen.getByText('Server error'));
});
it('calls onCancel when Cancel is clicked', async () => {
it('calls onCancel when Cancel is clicked on detail step', async () => {
mockRegistryGet.mockResolvedValue(DETAIL);
const onCancel = vi.fn();
render(
@@ -210,8 +236,71 @@ describe('InstallDialog', () => {
/>
);
await waitFor(() => screen.getByLabelText('API_KEY'));
await goToConfigureStep();
const input = screen.getByLabelText('API_KEY') as HTMLInputElement;
expect(input.value).toBe('prefilled-key');
});
it('installs directly from detail step when no env keys required', async () => {
const installedServer = {
server_id: 'srv-2',
...DETAIL_NO_ENV,
command_kind: 'node' as const,
command: 'node',
args: [],
env_keys: [],
installed_at: 2000,
};
mockRegistryGet.mockResolvedValue(DETAIL_NO_ENV);
mockInstall.mockResolvedValue(installedServer);
const onSuccess = vi.fn();
render(
<InstallDialog qualifiedName="acme/simple-server" onSuccess={onSuccess} onCancel={() => {}} />
);
await waitFor(() => screen.getByRole('button', { name: 'Install' }));
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Install' }));
});
expect(mockInstall).toHaveBeenCalledWith({
qualified_name: 'acme/simple-server',
env: {},
config: undefined,
});
expect(onSuccess).toHaveBeenCalledWith(installedServer);
});
it('shows connection info on detail step', async () => {
mockRegistryGet.mockResolvedValue({
...DETAIL,
connections: [
{ type: 'stdio', published: true },
{ type: 'http', published: false, deployment_url: 'https://example.com/mcp' },
],
});
render(
<InstallDialog qualifiedName="acme/test-server" onSuccess={() => {}} onCancel={() => {}} />
);
await waitFor(() => screen.getByText('Available connections'));
expect(screen.getByText('stdio')).toBeInTheDocument();
expect(screen.getByText('http')).toBeInTheDocument();
});
it('navigates back from configure to detail step', async () => {
mockRegistryGet.mockResolvedValue(DETAIL);
render(
<InstallDialog qualifiedName="acme/test-server" onSuccess={() => {}} onCancel={() => {}} />
);
await goToConfigureStep();
expect(screen.getByLabelText('API_KEY')).toBeInTheDocument();
fireEvent.click(screen.getByText(`← Test Server`));
await waitFor(() => {
expect(screen.getByText('A test server')).toBeInTheDocument();
});
});
});
+254 -61
View File
@@ -1,18 +1,27 @@
/**
* 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.
* Rich install screen for an MCP server.
*
* Two-step flow:
* 1. **Detail** — server overview (icon, title, author, description, stats,
* transport type). If no env vars are required, a single "Install" button
* kicks off the install directly.
* 2. **Configure** — env var inputs + optional raw JSON config, then install.
*
* Uses `mcpClientsApi.registryGet` for detail, `mcpClientsApi.install` for
* the install itself, and best-effort `mcpClientsApi.connect` post-install.
*/
import debug from 'debug';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import { mcpClientsApi } from '../../../services/api/mcpClientsApi';
import type { InstalledServer, SmitheryServerDetail } from './types';
import { deriveAuthor } from './McpServerCard';
import type { InstalledServer, SmitheryConnection, SmitheryServerDetail } from './types';
const log = debug('mcp-clients:install');
type Step = 'detail' | 'configure';
interface InstallDialogProps {
qualifiedName: string;
prefillEnv?: Record<string, string>;
@@ -20,24 +29,37 @@ interface InstallDialogProps {
onCancel: () => void;
}
function pickTransportLabel(connections: SmitheryConnection[]): string | null {
const types = new Set(connections.map(c => c.type));
if (types.has('stdio')) return 'stdio';
if (types.has('http')) return 'http';
return connections[0]?.type ?? null;
}
function formatUseCount(count: number): string {
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
return String(count);
}
const InstallDialog = ({ qualifiedName, prefillEnv, onSuccess, onCancel }: InstallDialogProps) => {
const { t } = useT();
const [detail, setDetail] = useState<SmitheryServerDetail | null>(null);
const [loadingDetail, setLoadingDetail] = useState(true);
const [detailError, setDetailError] = useState<string | null>(null);
const latestQualifiedNameRef = useRef(qualifiedName);
const [step, setStep] = useState<Step>('detail');
const [envValues, setEnvValues] = useState<Record<string, string>>({});
const [showEnv, setShowEnv] = useState<Record<string, boolean>>({});
const [configJson, setConfigJson] = useState('');
const [showAdvanced, setShowAdvanced] = useState(false);
const [installing, setInstalling] = useState(false);
const [installError, setInstallError] = useState<string | null>(null);
// Track the latest qualifiedName seen by the effect to guard against stale
// async responses when qualifiedName changes or the component unmounts.
const latestQualifiedNameRef = useRef(qualifiedName);
// Fetch server detail on mount or when qualifiedName changes.
useEffect(() => {
latestQualifiedNameRef.current = qualifiedName;
setLoadingDetail(true);
@@ -47,13 +69,8 @@ const InstallDialog = ({ qualifiedName, prefillEnv, onSuccess, onCancel }: Insta
mcpClientsApi
.registryGet(qualifiedName)
.then(d => {
// Discard response if a newer request has already been issued.
if (latestQualifiedNameRef.current !== requestedName) {
log('discarding stale detail response for %s', requestedName);
return;
}
if (latestQualifiedNameRef.current !== requestedName) return;
setDetail(d);
// Pre-fill env values from prop (suggested by config assistant) or empty.
const initial: Record<string, string> = {};
for (const key of d.required_env_keys ?? []) {
initial[key] = prefillEnv?.[key] ?? '';
@@ -74,6 +91,8 @@ const InstallDialog = ({ qualifiedName, prefillEnv, onSuccess, onCancel }: Insta
});
}, [qualifiedName, prefillEnv, t]);
const hasEnvKeys = (detail?.required_env_keys ?? []).length > 0;
const toggleShowEnv = useCallback((key: string) => {
setShowEnv(prev => ({ ...prev, [key]: !prev[key] }));
}, []);
@@ -85,7 +104,6 @@ const InstallDialog = ({ qualifiedName, prefillEnv, onSuccess, onCancel }: Insta
const handleInstall = useCallback(async () => {
if (!detail) return;
// Validate required keys are filled.
for (const key of detail.required_env_keys ?? []) {
if (!envValues[key]?.trim()) {
setInstallError(t('mcp.install.missingRequired').replace('{key}', key));
@@ -93,7 +111,6 @@ const InstallDialog = ({ qualifiedName, prefillEnv, onSuccess, onCancel }: Insta
}
}
// Parse optional JSON config.
let parsedConfig: unknown = undefined;
if (configJson.trim()) {
try {
@@ -115,10 +132,6 @@ const InstallDialog = ({ qualifiedName, prefillEnv, onSuccess, onCancel }: Insta
config: parsedConfig,
});
log('install success server_id=%s', server.server_id);
// Best-effort auto-connect — fire-and-forget so the dialog closes
// immediately on install success. A slow or failing handshake must
// never block onSuccess (the detail view surfaces errors and offers
// a Connect retry).
void mcpClientsApi
.connect(server.server_id)
.then(() => log('auto-connect success server_id=%s', server.server_id))
@@ -139,6 +152,17 @@ const InstallDialog = ({ qualifiedName, prefillEnv, onSuccess, onCancel }: Insta
}
}, [detail, envValues, configJson, qualifiedName, onSuccess, t]);
const handleDirectInstall = useCallback(async () => {
if (hasEnvKeys) {
setStep('configure');
setInstallError(null);
return;
}
await handleInstall();
}, [hasEnvKeys, handleInstall]);
// ── Loading / error states ───────────────────────────────────────────────
if (loadingDetail) {
return (
<div className="py-10 text-center text-sm text-stone-400 dark:text-neutral-500">
@@ -165,36 +189,195 @@ const InstallDialog = ({ qualifiedName, prefillEnv, onSuccess, onCancel }: Insta
if (!detail) return null;
const author = deriveAuthor(qualifiedName);
const transport = pickTransportLabel(detail.connections);
// ── Step 1: Detail overview ──────────────────────────────────────────────
if (step === 'detail') {
return (
<div className="space-y-5">
<button
type="button"
onClick={onCancel}
className="text-xs text-stone-500 dark:text-neutral-400 hover:underline">
{t('mcp.install.back')}
</button>
{/* Header */}
<div className="flex items-start gap-4">
{detail.icon_url ? (
<img
src={detail.icon_url}
alt=""
className="w-14 h-14 rounded-lg shrink-0 object-contain bg-white dark:bg-neutral-900 border border-stone-100 dark:border-neutral-800"
/>
) : (
<div className="w-14 h-14 rounded-lg shrink-0 bg-primary-100 dark:bg-primary-500/20 flex items-center justify-center text-2xl">
🔌
</div>
)}
<div className="min-w-0 flex-1">
<h3 className="text-lg font-semibold text-stone-900 dark:text-neutral-100">
{detail.display_name}
</h3>
{author && (
<p className="text-sm text-stone-500 dark:text-neutral-400 mt-0.5">
{t('mcp.install.by')} {author}
</p>
)}
</div>
</div>
{/* Stats badges */}
<div className="flex flex-wrap gap-2">
{transport && (
<span className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium bg-stone-100 dark:bg-neutral-800 text-stone-600 dark:text-neutral-300">
{transport === 'stdio'
? t('mcp.install.transportLocal')
: t('mcp.install.transportRemote')}
</span>
)}
{detail.use_count != null && detail.use_count > 0 && (
<span className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium bg-stone-100 dark:bg-neutral-800 text-stone-600 dark:text-neutral-300">
{t('mcp.install.useCount').replace('{count}', formatUseCount(detail.use_count))}
</span>
)}
{detail.is_deployed && (
<span className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium bg-sage-100 dark:bg-sage-500/20 text-sage-700 dark:text-sage-300">
{t('mcp.install.deployed')}
</span>
)}
{hasEnvKeys && (
<span className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-300">
{t('mcp.install.requiresConfig')}
</span>
)}
</div>
{/* Description */}
{detail.description && (
<div className="text-sm text-stone-600 dark:text-neutral-300 leading-relaxed whitespace-pre-line">
{detail.description}
</div>
)}
{/* Connections info */}
{detail.connections.length > 0 && (
<div className="rounded-lg border border-stone-150 dark:border-neutral-700/60 bg-stone-50 dark:bg-neutral-800/40 p-3">
<p className="text-xs font-medium text-stone-500 dark:text-neutral-400 mb-2">
{t('mcp.install.connections')}
</p>
<div className="space-y-1.5">
{detail.connections.map((conn, i) => (
<div
key={i}
className="flex items-center gap-2 text-xs text-stone-600 dark:text-neutral-300">
<span
className={`w-2 h-2 rounded-full shrink-0 ${conn.published ? 'bg-sage-500' : 'bg-stone-300 dark:bg-neutral-600'}`}
/>
<span className="font-mono">{conn.type}</span>
{conn.published && (
<span className="text-stone-400 dark:text-neutral-500">
({t('mcp.install.published')})
</span>
)}
{conn.deployment_url && (
<span className="text-stone-400 dark:text-neutral-500 truncate">
{conn.deployment_url}
</span>
)}
</div>
))}
</div>
</div>
)}
{/* Required env keys preview */}
{hasEnvKeys && (
<div className="rounded-lg border border-amber-200 dark:border-amber-500/20 bg-amber-50 dark:bg-amber-500/10 p-3">
<p className="text-xs font-medium text-amber-700 dark:text-amber-300 mb-1.5">
{t('mcp.install.requiredEnv')}
</p>
<div className="flex flex-wrap gap-1.5">
{detail.required_env_keys!.map(key => (
<code
key={key}
className="rounded bg-amber-100 dark:bg-amber-500/20 px-1.5 py-0.5 text-xs font-mono text-amber-800 dark:text-amber-200">
{key}
</code>
))}
</div>
</div>
)}
{/* Install error (shown when no-config install fails) */}
{installError && (
<div className="rounded-lg border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-4 py-3 text-sm text-coral-700 dark:text-coral-300">
{installError}
</div>
)}
{/* Actions */}
<div className="flex gap-2 pt-1">
<button
type="button"
disabled={installing}
onClick={() => void handleDirectInstall()}
className="rounded-lg bg-primary-500 px-5 py-2.5 text-sm font-medium text-white hover:bg-primary-600 disabled:opacity-50 transition-colors">
{installing
? t('mcp.install.installing')
: hasEnvKeys
? t('mcp.install.configureAndInstall')
: t('mcp.install.button')}
</button>
<button
type="button"
disabled={installing}
onClick={onCancel}
className="rounded-lg border border-stone-200 dark:border-neutral-700 px-5 py-2.5 text-sm font-medium text-stone-600 dark:text-neutral-300 hover:border-stone-300 dark:hover:border-neutral-600 disabled:opacity-50 transition-colors">
{t('common.cancel')}
</button>
</div>
</div>
);
}
// ── Step 2: Configure & install ──────────────────────────────────────────
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-start gap-3">
<button
type="button"
onClick={() => {
setStep('detail');
setInstallError(null);
}}
className="text-xs text-stone-500 dark:text-neutral-400 hover:underline">
{detail.display_name}
</button>
{/* Compact header */}
<div className="flex items-center gap-3">
{detail.icon_url ? (
<img
src={detail.icon_url}
alt=""
className="w-10 h-10 rounded shrink-0 object-contain bg-white dark:bg-neutral-900 border border-stone-100 dark:border-neutral-800"
className="w-8 h-8 rounded shrink-0 object-contain bg-white dark:bg-neutral-900"
/>
) : (
<div className="w-10 h-10 rounded shrink-0 bg-primary-100 dark:bg-primary-500/20 flex items-center justify-center text-lg">
<div className="w-8 h-8 rounded shrink-0 bg-primary-100 dark:bg-primary-500/20 flex items-center justify-center text-sm">
🔌
</div>
)}
<div>
<h3 className="text-base font-semibold text-stone-900 dark:text-neutral-100">
{t('mcp.install.title').replace('{name}', detail.display_name)}
</h3>
{detail.description && (
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-0.5">
{detail.description}
</p>
)}
</div>
<h3 className="text-base font-semibold text-stone-900 dark:text-neutral-100">
{t('mcp.install.configureTitle').replace('{name}', detail.display_name)}
</h3>
</div>
{/* Env var inputs */}
{(detail.required_env_keys ?? []).length > 0 && (
<div className="space-y-2">
{hasEnvKeys && (
<div className="space-y-3">
<p className="text-xs font-medium text-stone-700 dark:text-neutral-300">
{t('mcp.install.requiredEnv')}
</p>
@@ -202,7 +385,7 @@ const InstallDialog = ({ qualifiedName, prefillEnv, onSuccess, onCancel }: Insta
<div key={key} className="space-y-1">
<label
htmlFor={`env-${key}`}
className="block text-xs font-medium text-stone-600 dark:text-neutral-400">
className="block text-xs font-medium text-stone-600 dark:text-neutral-400 font-mono">
{key}
</label>
<div className="flex gap-2">
@@ -213,13 +396,13 @@ const InstallDialog = ({ qualifiedName, prefillEnv, onSuccess, onCancel }: Insta
onChange={e => handleEnvChange(key, e.target.value)}
placeholder={t('mcp.install.enterValue').replace('{key}', key)}
disabled={installing}
className="flex-1 rounded-lg border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-1.5 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 disabled:opacity-50"
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 disabled:opacity-50"
/>
<button
type="button"
onClick={() => toggleShowEnv(key)}
disabled={installing}
className="shrink-0 rounded-lg border border-stone-200 dark:border-neutral-700 px-2 py-1 text-xs text-stone-500 dark:text-neutral-400 hover:border-stone-300 dark:hover:border-neutral-600 disabled:opacity-50">
className="shrink-0 rounded-lg border border-stone-200 dark:border-neutral-700 px-2.5 py-1.5 text-xs text-stone-500 dark:text-neutral-400 hover:border-stone-300 dark:hover:border-neutral-600 disabled:opacity-50">
{showEnv[key] ? t('mcp.install.hide') : t('mcp.install.show')}
</button>
</div>
@@ -228,25 +411,35 @@ const InstallDialog = ({ qualifiedName, prefillEnv, onSuccess, onCancel }: Insta
</div>
)}
{/* Optional JSON config */}
<div className="space-y-1">
<label
htmlFor="mcp-config-json"
className="block text-xs font-medium text-stone-600 dark:text-neutral-400">
{t('mcp.install.configLabel')}
</label>
<textarea
id="mcp-config-json"
value={configJson}
onChange={e => setConfigJson(e.target.value)}
disabled={installing}
rows={4}
placeholder={t('mcp.install.configPlaceholder')}
className="w-full rounded-lg border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm font-mono 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 disabled:opacity-50 resize-y"
/>
{/* Advanced: optional JSON config */}
<div>
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="text-xs text-stone-500 dark:text-neutral-400 hover:text-stone-700 dark:hover:text-neutral-200 transition-colors">
{showAdvanced ? '▾' : '▸'} {t('mcp.install.advancedConfig')}
</button>
{showAdvanced && (
<div className="mt-2 space-y-1">
<label
htmlFor="mcp-config-json"
className="block text-xs font-medium text-stone-600 dark:text-neutral-400">
{t('mcp.install.configLabel')}
</label>
<textarea
id="mcp-config-json"
value={configJson}
onChange={e => setConfigJson(e.target.value)}
disabled={installing}
rows={4}
placeholder={t('mcp.install.configPlaceholder')}
className="w-full rounded-lg border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm font-mono 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 disabled:opacity-50 resize-y"
/>
</div>
)}
</div>
{/* Error */}
{/* Errors */}
{installError && (
<div className="rounded-lg border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-4 py-3 text-sm text-coral-700 dark:text-coral-300">
{installError}
@@ -254,19 +447,19 @@ const InstallDialog = ({ qualifiedName, prefillEnv, onSuccess, onCancel }: Insta
)}
{/* Actions */}
<div className="flex gap-2">
<div className="flex gap-2 pt-1">
<button
type="button"
disabled={installing}
onClick={() => void handleInstall()}
className="rounded-lg bg-primary-500 px-4 py-2 text-sm font-medium text-white hover:bg-primary-600 disabled:opacity-50 transition-colors">
className="rounded-lg bg-primary-500 px-5 py-2.5 text-sm font-medium text-white hover:bg-primary-600 disabled:opacity-50 transition-colors">
{installing ? t('mcp.install.installing') : t('mcp.install.button')}
</button>
<button
type="button"
disabled={installing}
onClick={onCancel}
className="rounded-lg border border-stone-200 dark:border-neutral-700 px-4 py-2 text-sm font-medium text-stone-600 dark:text-neutral-300 hover:border-stone-300 dark:hover:border-neutral-600 disabled:opacity-50">
className="rounded-lg border border-stone-200 dark:border-neutral-700 px-4 py-2.5 text-sm font-medium text-stone-600 dark:text-neutral-300 hover:border-stone-300 dark:hover:border-neutral-600 disabled:opacity-50 transition-colors">
{t('common.cancel')}
</button>
</div>
@@ -90,7 +90,7 @@ describe('McpCatalogBrowser', () => {
await waitFor(() => screen.getByText('File Server'));
fireEvent.click(screen.getByRole('button', { name: 'Install' }));
fireEvent.click(screen.getByRole('button', { name: /File Server/ }));
expect(onSelectInstall).toHaveBeenCalledWith('acme/file-server');
});
@@ -119,7 +119,7 @@ const McpCatalogBrowser = ({ onSelectInstall }: McpCatalogBrowserProps) => {
<McpServerCard
key={server.qualified_name}
server={server}
onInstall={onSelectInstall}
onSelect={onSelectInstall}
/>
))}
</div>
@@ -1,63 +1,50 @@
/**
* Card component for a single MCP registry server.
* Shows icon, name, description (clamped), usage count and deployed badge.
* Shows icon, title, description, and author derived from qualified name.
*/
import { useT } from '../../../lib/i18n/I18nContext';
import type { SmitheryServer } from './types';
interface McpServerCardProps {
server: SmitheryServer;
onInstall: (qualifiedName: string) => void;
onSelect: (qualifiedName: string) => void;
}
const McpServerCard = ({ server, onInstall }: McpServerCardProps) => {
const { t } = useT();
export function deriveAuthor(qualifiedName: string): string | null {
const slashIdx = qualifiedName.indexOf('/');
if (slashIdx < 1) return null;
const prefix = qualifiedName.slice(0, slashIdx);
const lastDot = prefix.lastIndexOf('.');
return lastDot >= 0 ? prefix.slice(lastDot + 1) : prefix;
}
const McpServerCard = ({ server, onSelect }: McpServerCardProps) => {
return (
<div className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-3 flex flex-col gap-2">
<div className="flex items-start gap-2">
{server.icon_url ? (
<img
src={server.icon_url}
alt=""
className="w-8 h-8 rounded shrink-0 object-contain bg-white dark:bg-neutral-900"
/>
) : (
<div className="w-8 h-8 rounded shrink-0 bg-primary-100 dark:bg-primary-500/20 flex items-center justify-center text-sm">
🔌
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<p className="text-sm font-medium text-stone-900 dark:text-neutral-100 truncate">
{server.display_name}
</p>
{server.is_deployed && (
<span className="shrink-0 px-1.5 py-0.5 text-[10px] border rounded-full bg-primary-50 dark:bg-primary-500/15 border-primary-200 dark:border-primary-500/30 text-primary-700 dark:text-primary-300">
{t('mcp.catalog.deployed')}
</span>
)}
</div>
{server.use_count != null && server.use_count > 0 && (
<p className="text-[11px] text-stone-400 dark:text-neutral-500 mt-0.5">
{t('mcp.catalog.installCount').replace('{count}', server.use_count.toLocaleString())}
</p>
)}
<button
type="button"
onClick={() => onSelect(server.qualified_name)}
className="w-full text-left rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-3 flex items-start gap-3 hover:border-primary-300 dark:hover:border-primary-500/40 hover:bg-stone-100/50 dark:hover:bg-neutral-800 transition-colors cursor-pointer">
{server.icon_url ? (
<img
src={server.icon_url}
alt=""
className="w-8 h-8 rounded shrink-0 object-contain bg-white dark:bg-neutral-900"
/>
) : (
<div className="w-8 h-8 rounded shrink-0 bg-primary-100 dark:bg-primary-500/20 flex items-center justify-center text-sm">
🔌
</div>
</div>
{server.description && (
<p className="text-xs text-stone-500 dark:text-neutral-400 line-clamp-2">
{server.description}
</p>
)}
<button
type="button"
onClick={() => onInstall(server.qualified_name)}
className="mt-auto w-full rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-600 transition-colors">
{t('mcp.install.button')}
</button>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-stone-900 dark:text-neutral-100 truncate">
{server.display_name}
</p>
{server.description && (
<p className="text-xs text-stone-500 dark:text-neutral-400 line-clamp-4 mt-0.5">
{server.description}
</p>
)}
</div>
</button>
);
};
@@ -130,9 +130,8 @@ describe('McpServersTab', () => {
await waitFor(() => {
expect(screen.getByText('File Server')).toBeInTheDocument();
});
// Table columns are present
expect(screen.getByText('Name')).toBeInTheDocument();
expect(screen.getByText('Source')).toBeInTheDocument();
expect(screen.getByText('Author')).toBeInTheDocument();
});
it('renders filter chips — All, Installed, Registry', async () => {
@@ -205,7 +204,6 @@ describe('McpServersTab', () => {
vi.useRealTimers();
await waitFor(() => screen.getByText('File Server'));
// Click the table row (not a button)
fireEvent.click(screen.getByText('File Server').closest('tr')!);
await waitFor(() => {
@@ -225,7 +223,6 @@ describe('McpServersTab', () => {
await waitFor(() => screen.getByText('acme/fs-server'));
// Back button navigates home
fireEvent.click(screen.getByText('Go back'));
await waitFor(() => {
expect(screen.queryByText('acme/fs-server')).not.toBeInTheDocument();
@@ -250,11 +247,10 @@ describe('McpServersTab', () => {
await waitFor(() => {
expect(screen.getByText('New Server')).toBeInTheDocument();
});
// Install button present for registry server
expect(screen.getByRole('button', { name: 'Install' })).toBeInTheDocument();
expect(screen.getByText('Install')).toBeInTheDocument();
});
it('navigates to install view when Install is clicked on a registry server', async () => {
it('navigates to install view when a registry server row is clicked', async () => {
mockInstalledList.mockResolvedValue([]);
mockStatus.mockResolvedValue([]);
mockRegistrySearch.mockResolvedValue({
@@ -268,7 +264,7 @@ describe('McpServersTab', () => {
vi.useRealTimers();
await waitFor(() => screen.getByText('New Server'));
fireEvent.click(screen.getByRole('button', { name: 'Install' }));
fireEvent.click(screen.getByText('New Server').closest('tr')!);
await waitFor(() => {
expect(screen.getByText('Loading server details...')).toBeInTheDocument();
@@ -296,13 +292,12 @@ describe('McpServersTab', () => {
vi.useRealTimers();
await waitFor(() => screen.getByText('New Server'));
fireEvent.click(screen.getByRole('button', { name: 'Install' }));
fireEvent.click(screen.getByText('New Server').closest('tr')!);
await waitFor(() => screen.getByRole('button', { name: 'Cancel' }));
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
await waitFor(() => {
// Back at home — search input is present
expect(screen.getByPlaceholderText('Search MCP servers...')).toBeInTheDocument();
});
});
@@ -350,8 +345,10 @@ describe('McpServersTab', () => {
vi.useRealTimers();
await waitFor(() => screen.getByText('New Server'));
fireEvent.click(screen.getByRole('button', { name: 'Install' }));
// Click the registry row to open install dialog
fireEvent.click(screen.getByText('New Server').closest('tr')!);
// Wait for detail to load, then click Install on the detail step
await waitFor(() => screen.getByRole('button', { name: 'Install' }));
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Install' }));
@@ -414,7 +411,9 @@ describe('McpServersTab', () => {
mockInstalledList.mockResolvedValue([newServer]);
await waitFor(() => screen.getByText('New Server'));
fireEvent.click(screen.getByRole('button', { name: 'Install' }));
// Click registry row to open install
fireEvent.click(screen.getByText('New Server').closest('tr')!);
// Wait for detail step, click Install
await waitFor(() => screen.getByRole('button', { name: 'Install' }));
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Install' }));
@@ -478,7 +477,7 @@ describe('McpServersTab', () => {
});
});
it('shows installed server with Installed badge in the table', async () => {
it('shows installed server with Manage action in the table', async () => {
mockInstalledList.mockResolvedValue(SERVERS);
mockStatus.mockResolvedValue(STATUSES_CONNECTED);
@@ -488,9 +487,6 @@ describe('McpServersTab', () => {
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();
});
@@ -13,6 +13,7 @@ import { mcpClientsApi } from '../../../services/api/mcpClientsApi';
import InstallDialog from './InstallDialog';
import InstalledServerDetail from './InstalledServerDetail';
import McpInventoryPanel from './McpInventoryPanel';
import { deriveAuthor } from './McpServerCard';
import type { ConnStatus, InstalledServer, ServerStatus, SmitheryServer } from './types';
const log = debug('mcp-clients:tab');
@@ -321,11 +322,8 @@ const McpServersTab = () => {
<th className="text-left px-4 py-2.5 text-xs font-medium text-stone-500 dark:text-neutral-400">
{t('mcp.tab.column.name')}
</th>
<th className="text-left px-4 py-2.5 text-xs font-medium text-stone-500 dark:text-neutral-400 hidden sm:table-cell">
{t('mcp.tab.column.description')}
</th>
<th className="text-left px-4 py-2.5 text-xs font-medium text-stone-500 dark:text-neutral-400 w-24">
{t('mcp.tab.column.source')}
<th className="text-left px-4 py-2.5 text-xs font-medium text-stone-500 dark:text-neutral-400 hidden sm:table-cell w-36">
{t('mcp.tab.column.author')}
</th>
<th className="text-right px-4 py-2.5 text-xs font-medium text-stone-500 dark:text-neutral-400 w-28">
{t('mcp.tab.column.action')}
@@ -361,19 +359,21 @@ const McpServersTab = () => {
className={`w-2 h-2 rounded-full shrink-0 ${STATUS_DOT[status]}`}
title={status}
/>
<span className="font-medium text-stone-900 dark:text-neutral-100 truncate">
{server.display_name}
</span>
<div className="min-w-0">
<span className="font-medium text-stone-900 dark:text-neutral-100 truncate block">
{server.display_name}
</span>
{server.description && (
<span className="text-xs text-stone-400 dark:text-neutral-500 line-clamp-4 block">
{server.description}
</span>
)}
</div>
</div>
</td>
<td className="px-4 py-3 hidden sm:table-cell">
<span className="text-stone-500 dark:text-neutral-400 line-clamp-1 text-xs">
{server.description ?? '—'}
</span>
</td>
<td className="px-4 py-3">
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold bg-sage-100 dark:bg-sage-500/15 text-sage-700 dark:text-sage-300 border border-sage-200 dark:border-sage-500/30">
{t('mcp.tab.badge.installed')}
<span className="text-xs text-stone-500 dark:text-neutral-400 truncate block">
{deriveAuthor(server.qualified_name) ?? '—'}
</span>
</td>
<td className="px-4 py-3 text-right">
@@ -390,7 +390,20 @@ const McpServersTab = () => {
filteredCatalog.map(server => (
<tr
key={`catalog-${server.qualified_name}`}
className="hover:bg-stone-50 dark:hover:bg-neutral-800/40 transition-colors">
className="hover:bg-stone-50 dark:hover:bg-neutral-800/40 cursor-pointer transition-colors"
tabIndex={0}
role="button"
aria-label={t('mcp.tab.aria.installServer').replace(
'{name}',
server.display_name
)}
onClick={() => handleSelectInstall(server.qualified_name)}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleSelectInstall(server.qualified_name);
}
}}>
<td className="px-4 py-3">
<div className="flex items-center gap-2.5">
{server.icon_url ? (
@@ -404,28 +417,27 @@ const McpServersTab = () => {
🔌
</span>
)}
<span className="font-medium text-stone-900 dark:text-neutral-100 truncate">
{server.display_name}
</span>
<div className="min-w-0">
<span className="font-medium text-stone-900 dark:text-neutral-100 truncate block">
{server.display_name}
</span>
{server.description && (
<span className="text-xs text-stone-400 dark:text-neutral-500 line-clamp-4 block">
{server.description}
</span>
)}
</div>
</div>
</td>
<td className="px-4 py-3 hidden sm:table-cell">
<span className="text-stone-500 dark:text-neutral-400 line-clamp-1 text-xs">
{server.description ?? '—'}
</span>
</td>
<td className="px-4 py-3">
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold bg-primary-50 dark:bg-primary-500/15 text-primary-700 dark:text-primary-300 border border-primary-200 dark:border-primary-500/30">
{t('mcp.tab.badge.registry')}
<span className="text-xs text-stone-500 dark:text-neutral-400 truncate block">
{deriveAuthor(server.qualified_name) ?? '—'}
</span>
</td>
<td className="px-4 py-3 text-right">
<button
type="button"
onClick={() => handleSelectInstall(server.qualified_name)}
className="rounded-lg bg-primary-500 px-3 py-1 text-xs font-medium text-white hover:bg-primary-600 transition-colors">
<span className="text-xs text-primary-600 dark:text-primary-400 font-medium">
{t('mcp.install.button')}
</button>
</span>
</td>
</tr>
))}
+13
View File
@@ -1156,11 +1156,13 @@ const messages: TranslationMap = {
'mcp.tab.column.name': 'الاسم',
'mcp.tab.column.description': 'الوصف',
'mcp.tab.column.source': 'المصدر',
'mcp.tab.column.author': 'المؤلف',
'mcp.tab.column.action': 'الإجراء',
'mcp.tab.badge.installed': 'مثبت',
'mcp.tab.badge.registry': 'السجل',
'mcp.tab.action.manage': 'إدارة',
'mcp.tab.aria.viewDetails': 'عرض تفاصيل {name}',
'mcp.tab.aria.installServer': 'تثبيت {name}',
'mcp.install.loadingDetail': 'جارٍ تحميل تفاصيل الخادم...',
'mcp.install.back': 'العودة',
'mcp.install.title': 'تثبيت {name}',
@@ -1176,6 +1178,17 @@ const messages: TranslationMap = {
'mcp.install.failedInstall': 'فشل التثبيت',
'mcp.install.button': 'تثبيت',
'mcp.install.installing': 'جارٍ التثبيت...',
'mcp.install.by': 'بواسطة',
'mcp.install.transportLocal': 'يعمل محليًا',
'mcp.install.transportRemote': 'مستضاف سحابيًا',
'mcp.install.useCount': 'عمليات تثبيت {count}',
'mcp.install.deployed': 'منشور',
'mcp.install.requiresConfig': 'يتطلب تكوينًا',
'mcp.install.connections': 'الاتصالات المتاحة',
'mcp.install.published': 'منشور',
'mcp.install.configureAndInstall': 'تكوين وتثبيت',
'mcp.install.configureTitle': 'تكوين {name}',
'mcp.install.advancedConfig': 'تكوين متقدم',
'mcp.detail.suggestedEnvReady': 'قيم البيئة المقترحة جاهزة',
'mcp.detail.suggestedEnvBody': 'أعِد تثبيت هذا الخادم بالقيم المقترحة لتطبيقها: {keys}',
'mcp.detail.connect': 'اتصال',
+13
View File
@@ -1176,11 +1176,13 @@ const messages: TranslationMap = {
'mcp.tab.column.name': 'নাম',
'mcp.tab.column.description': 'বিবরণ',
'mcp.tab.column.source': 'উৎস',
'mcp.tab.column.author': 'লেখক',
'mcp.tab.column.action': 'ক্রিয়া',
'mcp.tab.badge.installed': 'ইনস্টল করা',
'mcp.tab.badge.registry': 'রেজিস্ট্রি',
'mcp.tab.action.manage': 'পরিচালনা',
'mcp.tab.aria.viewDetails': '{name} এর বিবরণ দেখুন',
'mcp.tab.aria.installServer': '{name} ইনস্টল করুন',
'mcp.install.loadingDetail': 'সার্ভারের বিবরণ লোড হচ্ছে...',
'mcp.install.back': 'ফিরে যান',
'mcp.install.title': '{name} ইনস্টল করুন',
@@ -1196,6 +1198,17 @@ const messages: TranslationMap = {
'mcp.install.failedInstall': 'ইনস্টল ব্যর্থ হয়েছে',
'mcp.install.button': 'ইনস্টল করুন',
'mcp.install.installing': 'ইনস্টল করা হচ্ছে...',
'mcp.install.by': 'দ্বারা',
'mcp.install.transportLocal': 'স্থানীয়ভাবে চলে',
'mcp.install.transportRemote': 'ক্লাউডে হোস্ট করা',
'mcp.install.useCount': '{count} ইনস্টলেশন',
'mcp.install.deployed': 'স্থাপিত',
'mcp.install.requiresConfig': 'কনফিগারেশন প্রয়োজন',
'mcp.install.connections': 'উপলব্ধ সংযোগ',
'mcp.install.published': 'প্রকাশিত',
'mcp.install.configureAndInstall': 'কনফিগার করুন ও ইনস্টল করুন',
'mcp.install.configureTitle': '{name} কনফিগার করুন',
'mcp.install.advancedConfig': 'উন্নত কনফিগারেশন',
'mcp.detail.suggestedEnvReady': 'প্রস্তাবিত পরিবেশ মান প্রস্তুত',
'mcp.detail.suggestedEnvBody':
'এই সার্ভারে-ইনস্টল করার জন্য পরামর্শ দেওয়া হল: xqxq0x ব্যবহার করুন',
+13
View File
@@ -1214,11 +1214,13 @@ const messages: TranslationMap = {
'mcp.tab.column.name': 'Name',
'mcp.tab.column.description': 'Beschreibung',
'mcp.tab.column.source': 'Quelle',
'mcp.tab.column.author': 'Autor',
'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.tab.aria.installServer': '{name} installieren',
'mcp.install.loadingDetail': 'Serverdetails werden geladen...',
'mcp.install.back': 'Zurück',
'mcp.install.title': 'Installieren Sie {name}',
@@ -1234,6 +1236,17 @@ const messages: TranslationMap = {
'mcp.install.failedInstall': 'Installation fehlgeschlagen',
'mcp.install.button': 'Installation',
'mcp.install.installing': 'Installation...',
'mcp.install.by': 'von',
'mcp.install.transportLocal': 'Lokal ausgeführt',
'mcp.install.transportRemote': 'Cloud-gehostet',
'mcp.install.useCount': '{count} Installationen',
'mcp.install.deployed': 'Bereitgestellt',
'mcp.install.requiresConfig': 'Konfiguration erforderlich',
'mcp.install.connections': 'Verfügbare Verbindungen',
'mcp.install.published': 'veröffentlicht',
'mcp.install.configureAndInstall': 'Konfigurieren & installieren',
'mcp.install.configureTitle': '{name} konfigurieren',
'mcp.install.advancedConfig': 'Erweiterte Konfiguration',
'mcp.detail.suggestedEnvReady': 'Vorgeschlagene Umgebungswerte bereit',
'mcp.detail.suggestedEnvBody':
'Installieren Sie diesen Server mit den vorgeschlagenen Werten neu, um sie anzuwenden: {keys}',
+13
View File
@@ -1504,11 +1504,13 @@ const en: TranslationMap = {
'mcp.tab.column.name': 'Name',
'mcp.tab.column.description': 'Description',
'mcp.tab.column.source': 'Source',
'mcp.tab.column.author': 'Author',
'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.tab.aria.installServer': 'Install {name}',
'mcp.install.loadingDetail': 'Loading server details...',
'mcp.install.back': 'Go back',
'mcp.install.title': 'Install {name}',
@@ -1524,6 +1526,17 @@ const en: TranslationMap = {
'mcp.install.failedInstall': 'Install failed',
'mcp.install.button': 'Install',
'mcp.install.installing': 'Installing...',
'mcp.install.by': 'by',
'mcp.install.transportLocal': 'Runs locally',
'mcp.install.transportRemote': 'Cloud hosted',
'mcp.install.useCount': '{count} installs',
'mcp.install.deployed': 'Deployed',
'mcp.install.requiresConfig': 'Requires configuration',
'mcp.install.connections': 'Available connections',
'mcp.install.published': 'published',
'mcp.install.configureAndInstall': 'Configure & install',
'mcp.install.configureTitle': 'Configure {name}',
'mcp.install.advancedConfig': 'Advanced configuration',
'mcp.detail.suggestedEnvReady': 'Suggested environment values ready',
'mcp.detail.suggestedEnvBody':
'Re-install this server with the suggested values to apply them: {keys}',
+13
View File
@@ -1210,11 +1210,13 @@ const messages: TranslationMap = {
'mcp.tab.column.name': 'Nombre',
'mcp.tab.column.description': 'Descripción',
'mcp.tab.column.source': 'Origen',
'mcp.tab.column.author': 'Autor',
'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.tab.aria.installServer': 'Instalar {name}',
'mcp.install.loadingDetail': 'Cargando detalles del servidor...',
'mcp.install.back': 'volver',
'mcp.install.title': 'Instalar {name}',
@@ -1230,6 +1232,17 @@ const messages: TranslationMap = {
'mcp.install.failedInstall': 'La instalación falló',
'mcp.install.button': 'Instalar',
'mcp.install.installing': 'Instalando...',
'mcp.install.by': 'por',
'mcp.install.transportLocal': 'Se ejecuta localmente',
'mcp.install.transportRemote': 'Alojado en la nube',
'mcp.install.useCount': '{count} instalaciones',
'mcp.install.deployed': 'Desplegado',
'mcp.install.requiresConfig': 'Requiere configuración',
'mcp.install.connections': 'Conexiones disponibles',
'mcp.install.published': 'publicado',
'mcp.install.configureAndInstall': 'Configurar e instalar',
'mcp.install.configureTitle': 'Configurar {name}',
'mcp.install.advancedConfig': 'Configuración avanzada',
'mcp.detail.suggestedEnvReady': 'Valores ambientales sugeridos listos',
'mcp.detail.suggestedEnvBody':
'Reinstale este servidor con los valores sugeridos para aplicarlos: {keys}',
+13
View File
@@ -1214,11 +1214,13 @@ const messages: TranslationMap = {
'mcp.tab.column.name': 'Nom',
'mcp.tab.column.description': 'Description',
'mcp.tab.column.source': 'Source',
'mcp.tab.column.author': 'Auteur',
'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.tab.aria.installServer': 'Installer {name}',
'mcp.install.loadingDetail': 'Chargement des détails du serveur...',
'mcp.install.back': 'Retourner',
'mcp.install.title': 'Installer {name}',
@@ -1234,6 +1236,17 @@ const messages: TranslationMap = {
'mcp.install.failedInstall': "Échec de l'installation",
'mcp.install.button': 'Installation',
'mcp.install.installing': 'Installation...',
'mcp.install.by': 'par',
'mcp.install.transportLocal': 'Exécution locale',
'mcp.install.transportRemote': 'Hébergé dans le cloud',
'mcp.install.useCount': '{count} installations',
'mcp.install.deployed': 'Déployé',
'mcp.install.requiresConfig': 'Configuration requise',
'mcp.install.connections': 'Connexions disponibles',
'mcp.install.published': 'publié',
'mcp.install.configureAndInstall': 'Configurer et installer',
'mcp.install.configureTitle': 'Configurer {name}',
'mcp.install.advancedConfig': 'Configuration avancée',
'mcp.detail.suggestedEnvReady': "Valeurs d'environnement suggérées prêtes",
'mcp.detail.suggestedEnvBody':
'Réinstallez ce serveur avec les valeurs suggérées pour les appliquer: {keys}',
+13
View File
@@ -1177,11 +1177,13 @@ const messages: TranslationMap = {
'mcp.tab.column.name': 'नाम',
'mcp.tab.column.description': 'विवरण',
'mcp.tab.column.source': 'स्रोत',
'mcp.tab.column.author': 'लेखक',
'mcp.tab.column.action': 'क्रिया',
'mcp.tab.badge.installed': 'इंस्टॉल किया गया',
'mcp.tab.badge.registry': 'रजिस्ट्री',
'mcp.tab.action.manage': 'प्रबंधित करें',
'mcp.tab.aria.viewDetails': '{name} के विवरण देखें',
'mcp.tab.aria.installServer': '{name} इंस्टॉल करें',
'mcp.install.loadingDetail': 'सर्वर विवरण लोड हो रहा है...',
'mcp.install.back': 'वापस जाओ',
'mcp.install.title': '{name} स्थापित करें',
@@ -1197,6 +1199,17 @@ const messages: TranslationMap = {
'mcp.install.failedInstall': 'इंस्टॉल विफल',
'mcp.install.button': 'स्थापित करें',
'mcp.install.installing': 'स्थापित किया जा रहा है...',
'mcp.install.by': 'द्वारा',
'mcp.install.transportLocal': 'स्थानीय रूप से चलता है',
'mcp.install.transportRemote': 'क्लाउड होस्टेड',
'mcp.install.useCount': '{count} इंस्टॉलेशन',
'mcp.install.deployed': 'तैनात',
'mcp.install.requiresConfig': 'कॉन्फ़िगरेशन आवश्यक',
'mcp.install.connections': 'उपलब्ध कनेक्शन',
'mcp.install.published': 'प्रकाशित',
'mcp.install.configureAndInstall': 'कॉन्फ़िगर करें और इंस्टॉल करें',
'mcp.install.configureTitle': '{name} कॉन्फ़िगर करें',
'mcp.install.advancedConfig': 'उन्नत कॉन्फ़िगरेशन',
'mcp.detail.suggestedEnvReady': 'सुझाए गए पर्यावरण मूल्य तैयार हैं',
'mcp.detail.suggestedEnvBody':
'इस सर्वर को फिर से स्थापित करने के लिए सुझाए गए मूल्यों के साथ उन्हें लागू करने के लिए: {keys}',
+13
View File
@@ -1185,11 +1185,13 @@ const messages: TranslationMap = {
'mcp.tab.column.name': 'Nama',
'mcp.tab.column.description': 'Deskripsi',
'mcp.tab.column.source': 'Sumber',
'mcp.tab.column.author': 'Penulis',
'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.tab.aria.installServer': 'Instal {name}',
'mcp.install.loadingDetail': 'Memuat detail server...',
'mcp.install.back': 'Kembali',
'mcp.install.title': 'Instal {name}',
@@ -1205,6 +1207,17 @@ const messages: TranslationMap = {
'mcp.install.failedInstall': 'Penginstalan gagal',
'mcp.install.button': 'Penginstalan',
'mcp.install.installing': 'Penginstalan...',
'mcp.install.by': 'oleh',
'mcp.install.transportLocal': 'Berjalan secara lokal',
'mcp.install.transportRemote': 'Di-host di cloud',
'mcp.install.useCount': '{count} instalasi',
'mcp.install.deployed': 'Diterapkan',
'mcp.install.requiresConfig': 'Memerlukan konfigurasi',
'mcp.install.connections': 'Koneksi tersedia',
'mcp.install.published': 'diterbitkan',
'mcp.install.configureAndInstall': 'Konfigurasi & instal',
'mcp.install.configureTitle': 'Konfigurasi {name}',
'mcp.install.advancedConfig': 'Konfigurasi lanjutan',
'mcp.detail.suggestedEnvReady': 'Nilai lingkungan yang disarankan sudah siap',
'mcp.detail.suggestedEnvBody':
'Instal ulang server ini dengan nilai yang disarankan untuk diterapkan: {keys}',
+13
View File
@@ -1206,11 +1206,13 @@ const messages: TranslationMap = {
'mcp.tab.column.name': 'Nome',
'mcp.tab.column.description': 'Descrizione',
'mcp.tab.column.source': 'Origine',
'mcp.tab.column.author': 'Autore',
'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.tab.aria.installServer': 'Installa {name}',
'mcp.install.loadingDetail': 'Caricamento dettagli server...',
'mcp.install.back': 'Torna indietro',
'mcp.install.title': 'Installa {name}',
@@ -1226,6 +1228,17 @@ const messages: TranslationMap = {
'mcp.install.failedInstall': 'Installazione non riuscita',
'mcp.install.button': 'Installa',
'mcp.install.installing': 'Installazione...',
'mcp.install.by': 'di',
'mcp.install.transportLocal': 'Esecuzione locale',
'mcp.install.transportRemote': 'Ospitato nel cloud',
'mcp.install.useCount': '{count} installazioni',
'mcp.install.deployed': 'Distribuito',
'mcp.install.requiresConfig': 'Configurazione richiesta',
'mcp.install.connections': 'Connessioni disponibili',
'mcp.install.published': 'pubblicato',
'mcp.install.configureAndInstall': 'Configura e installa',
'mcp.install.configureTitle': 'Configura {name}',
'mcp.install.advancedConfig': 'Configurazione avanzata',
'mcp.detail.suggestedEnvReady': 'Valori di ambiente suggeriti pronti',
'mcp.detail.suggestedEnvBody':
'Reinstalla questo server con i valori suggeriti per applicarli: {keys}',
+13
View File
@@ -1172,11 +1172,13 @@ const messages: TranslationMap = {
'mcp.tab.column.name': '이름',
'mcp.tab.column.description': '설명',
'mcp.tab.column.source': '출처',
'mcp.tab.column.author': '작성자',
'mcp.tab.column.action': '작업',
'mcp.tab.badge.installed': '설치됨',
'mcp.tab.badge.registry': '레지스트리',
'mcp.tab.action.manage': '관리',
'mcp.tab.aria.viewDetails': '{name} 세부정보 보기',
'mcp.tab.aria.installServer': '{name} 설치',
'mcp.install.loadingDetail': '서버 세부정보 로드 중...',
'mcp.install.back': '뒤로 가기',
'mcp.install.title': '{name} 설치',
@@ -1192,6 +1194,17 @@ const messages: TranslationMap = {
'mcp.install.failedInstall': '설치에 실패했습니다.',
'mcp.install.button': '설치',
'mcp.install.installing': '설치 중...',
'mcp.install.by': '제작',
'mcp.install.transportLocal': '로컬 실행',
'mcp.install.transportRemote': '클라우드 호스팅',
'mcp.install.useCount': '{count}회 설치',
'mcp.install.deployed': '배포됨',
'mcp.install.requiresConfig': '구성 필요',
'mcp.install.connections': '사용 가능한 연결',
'mcp.install.published': '게시됨',
'mcp.install.configureAndInstall': '구성 및 설치',
'mcp.install.configureTitle': '{name} 구성',
'mcp.install.advancedConfig': '고급 구성',
'mcp.detail.suggestedEnvReady': '제안된 환경 값이 준비되었습니다.',
'mcp.detail.suggestedEnvBody':
'제안 값을 적용하려면 이 서버를 해당 값으로 다시 설치하세요: {keys}',
+13
View File
@@ -1197,11 +1197,13 @@ const messages: TranslationMap = {
'mcp.tab.column.name': 'Nazwa',
'mcp.tab.column.description': 'Opis',
'mcp.tab.column.source': 'Źródło',
'mcp.tab.column.author': 'Autor',
'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.tab.aria.installServer': 'Zainstaluj {name}',
'mcp.install.loadingDetail': 'Ładowanie szczegółów serwera...',
'mcp.install.back': 'Wstecz',
'mcp.install.title': 'Zainstaluj {name}',
@@ -1217,6 +1219,17 @@ const messages: TranslationMap = {
'mcp.install.failedInstall': 'Instalacja nieudana',
'mcp.install.button': 'Zainstaluj',
'mcp.install.installing': 'Instalowanie...',
'mcp.install.by': 'autor:',
'mcp.install.transportLocal': 'Uruchamiane lokalnie',
'mcp.install.transportRemote': 'Hostowane w chmurze',
'mcp.install.useCount': '{count} instalacji',
'mcp.install.deployed': 'Wdrożone',
'mcp.install.requiresConfig': 'Wymaga konfiguracji',
'mcp.install.connections': 'Dostępne połączenia',
'mcp.install.published': 'opublikowane',
'mcp.install.configureAndInstall': 'Skonfiguruj i zainstaluj',
'mcp.install.configureTitle': 'Skonfiguruj {name}',
'mcp.install.advancedConfig': 'Zaawansowana konfiguracja',
'mcp.detail.suggestedEnvReady': 'Gotowe sugerowane wartości środowiska',
'mcp.detail.suggestedEnvBody':
'Zainstaluj ten serwer ponownie z sugerowanymi wartościami, aby je zastosować: {keys}',
+13
View File
@@ -1211,11 +1211,13 @@ const messages: TranslationMap = {
'mcp.tab.column.name': 'Nome',
'mcp.tab.column.description': 'Descrição',
'mcp.tab.column.source': 'Origem',
'mcp.tab.column.author': 'Autor',
'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.tab.aria.installServer': 'Instalar {name}',
'mcp.install.loadingDetail': 'Carregando detalhes do servidor...',
'mcp.install.back': 'Voltar',
'mcp.install.title': 'Instalar {name}',
@@ -1231,6 +1233,17 @@ const messages: TranslationMap = {
'mcp.install.failedInstall': 'Falha na instalação',
'mcp.install.button': 'Instalação',
'mcp.install.installing': 'Instalando...',
'mcp.install.by': 'por',
'mcp.install.transportLocal': 'Execução local',
'mcp.install.transportRemote': 'Hospedado na nuvem',
'mcp.install.useCount': '{count} instalações',
'mcp.install.deployed': 'Implantado',
'mcp.install.requiresConfig': 'Requer configuração',
'mcp.install.connections': 'Conexões disponíveis',
'mcp.install.published': 'publicado',
'mcp.install.configureAndInstall': 'Configurar e instalar',
'mcp.install.configureTitle': 'Configurar {name}',
'mcp.install.advancedConfig': 'Configuração avançada',
'mcp.detail.suggestedEnvReady': 'Valores de ambiente sugeridos prontos',
'mcp.detail.suggestedEnvBody':
'Reinstale este servidor com os valores sugeridos para aplicá-los: {keys}',
+13
View File
@@ -1192,11 +1192,13 @@ const messages: TranslationMap = {
'mcp.tab.column.name': 'Название',
'mcp.tab.column.description': 'Описание',
'mcp.tab.column.source': 'Источник',
'mcp.tab.column.author': 'Автор',
'mcp.tab.column.action': 'Действие',
'mcp.tab.badge.installed': 'Установлено',
'mcp.tab.badge.registry': 'Реестр',
'mcp.tab.action.manage': 'Управление',
'mcp.tab.aria.viewDetails': 'Просмотреть детали {name}',
'mcp.tab.aria.installServer': 'Установить {name}',
'mcp.install.loadingDetail': 'Загрузка сведений о сервере...',
'mcp.install.back': 'Вернитесь назад',
'mcp.install.title': 'Установите {name}',
@@ -1212,6 +1214,17 @@ const messages: TranslationMap = {
'mcp.install.failedInstall': 'Не удалось установить',
'mcp.install.button': 'Установить',
'mcp.install.installing': 'Установка...',
'mcp.install.by': 'от',
'mcp.install.transportLocal': 'Локальный запуск',
'mcp.install.transportRemote': 'Облачный хостинг',
'mcp.install.useCount': '{count} установок',
'mcp.install.deployed': 'Развёрнуто',
'mcp.install.requiresConfig': 'Требуется настройка',
'mcp.install.connections': 'Доступные подключения',
'mcp.install.published': 'опубликовано',
'mcp.install.configureAndInstall': 'Настроить и установить',
'mcp.install.configureTitle': 'Настройка {name}',
'mcp.install.advancedConfig': 'Расширенная настройка',
'mcp.detail.suggestedEnvReady': 'Рекомендуемые значения среды готовы',
'mcp.detail.suggestedEnvBody':
'Чтобы применить их, переустановите этот сервер с предложенными значениями: {keys}.',
+13
View File
@@ -1118,11 +1118,13 @@ const messages: TranslationMap = {
'mcp.tab.column.name': '名称',
'mcp.tab.column.description': '描述',
'mcp.tab.column.source': '来源',
'mcp.tab.column.author': '作者',
'mcp.tab.column.action': '操作',
'mcp.tab.badge.installed': '已安装',
'mcp.tab.badge.registry': '注册表',
'mcp.tab.action.manage': '管理',
'mcp.tab.aria.viewDetails': '查看 {name} 的详情',
'mcp.tab.aria.installServer': '安装 {name}',
'mcp.install.loadingDetail': '正在加载服务器详细信息...',
'mcp.install.back': '返回',
'mcp.install.title': '安装 {name}',
@@ -1138,6 +1140,17 @@ const messages: TranslationMap = {
'mcp.install.failedInstall': '安装失败',
'mcp.install.button': '安装',
'mcp.install.installing': '正在安装...',
'mcp.install.by': '作者',
'mcp.install.transportLocal': '本地运行',
'mcp.install.transportRemote': '云端托管',
'mcp.install.useCount': '{count} 次安装',
'mcp.install.deployed': '已部署',
'mcp.install.requiresConfig': '需要配置',
'mcp.install.connections': '可用连接',
'mcp.install.published': '已发布',
'mcp.install.configureAndInstall': '配置并安装',
'mcp.install.configureTitle': '配置 {name}',
'mcp.install.advancedConfig': '高级配置',
'mcp.detail.suggestedEnvReady': '建议的环境值已准备好',
'mcp.detail.suggestedEnvBody': '使用建议值重新安装此服务器以应用它们:{keys}',
'mcp.detail.connect': '连接',
@@ -74,7 +74,7 @@ describe('Skills page — MCP tab', () => {
});
expect(screen.getByText('Name')).toBeInTheDocument();
expect(screen.getByText('Source')).toBeInTheDocument();
expect(screen.getByText('Author')).toBeInTheDocument();
expect(screen.getByText('Action')).toBeInTheDocument();
});
+39 -34
View File
@@ -260,15 +260,18 @@ test.describe('MCP Tab — Table View & Filtering', () => {
await expect(page.getByRole('button', { name: /Registry/ })).toBeVisible();
});
test('displays installed servers with status dot and chip', async ({ page }) => {
test('displays installed servers with status dot and Manage action', 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();
await expect(row.locator('text=Manage')).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('displays registry servers as clickable rows', async ({ page }) => {
const registryRow = page.locator('table tbody tr[role="button"]', {
has: page.locator('text=GitHub Tools'),
});
await expect(registryRow).toBeVisible({ timeout: 10_000 });
await expect(registryRow.locator('text=Install')).toBeVisible();
});
test('filter "Installed" hides registry rows', async ({ page }) => {
@@ -277,7 +280,7 @@ test.describe('MCP Tab — Table View & Filtering', () => {
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');
await expect(rows.nth(i).locator('text=Manage')).toBeVisible();
}
});
@@ -287,7 +290,7 @@ test.describe('MCP Tab — Table View & Filtering', () => {
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');
await expect(rows.nth(i).locator('text=Install')).toBeVisible();
}
});
@@ -331,31 +334,37 @@ test.describe('MCP Tab — Install Lifecycle', () => {
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', {
test('install flow: click row → detail → configure → fill env → submit → appears installed', async ({
page,
}) => {
// 1. Click the GitHub Tools registry row (entire row is clickable)
const githubRow = page.locator('table tbody tr[role="button"]', {
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();
await githubRow.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');
// 2. Install dialog detail step — shows server info and "Configure & install"
await expect(page.locator('text=GitHub Tools').first()).toBeVisible({ timeout: 5_000 });
const configureBtn = page.locator('button:has-text("Configure & install")');
await expect(configureBtn).toBeVisible({ timeout: 5_000 });
await configureBtn.click();
// 3. Configure step — env input appears
const envInput = page.locator('input[id="env-GITHUB_TOKEN"]');
await expect(envInput).toBeVisible({ timeout: 5_000 });
// 3. Fill in the env value
// 4. 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();
// 5. Click "Install" submit button
const submitBtn = page.locator('button:has-text("Install")');
await submitBtn.click();
// 5. Should navigate to detail view (the installed server detail)
// Back button should still be visible in the detail view
// 6. Should navigate to detail view (the installed server detail)
await expect(page.locator('button:has-text("Back")')).toBeVisible({ timeout: 10_000 });
// 6. Go back and verify the server appears in the installed list
// 7. 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', {
@@ -364,11 +373,16 @@ test.describe('MCP Tab — Install Lifecycle', () => {
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();
test('cancel from install dialog returns to table', async ({ page }) => {
// Click a registry row to open install dialog
const registryRow = page.locator('table tbody tr[role="button"]', {
has: page.locator('td:first-child:has-text("GitHub Tools")'),
});
await registryRow.click();
// Cancel button should be visible on detail step
await expect(page.locator('button:has-text("Cancel")')).toBeVisible({ timeout: 5_000 });
await page.locator('button:has-text("Cancel")').click();
await expect(page.locator('table')).toBeVisible({ timeout: 5_000 });
});
});
@@ -384,13 +398,11 @@ test.describe('MCP Tab — Manage & Uninstall Lifecycle', () => {
});
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();
});
@@ -405,27 +417,22 @@ test.describe('MCP Tab — Manage & Uninstall Lifecycle', () => {
});
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")'),
@@ -452,7 +459,6 @@ test.describe('MCP Tab — Empty & Edge States', () => {
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 });
});
@@ -462,7 +468,6 @@ test.describe('MCP Tab — Empty & Edge States', () => {
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 (
@@ -976,6 +976,173 @@ mod tests {
assert_eq!(required, vec!["API_KEY"]);
}
// ── display_name / title derivation ────────────────────────────────────
#[test]
fn display_name_uses_title_when_present() {
let s: OfficialServer = serde_json::from_value(json!({
"name": "io.github.modelcontextprotocol/server-filesystem",
"title": "Filesystem MCP Server",
}))
.unwrap();
assert_eq!(s.display_name(), "Filesystem MCP Server");
}
#[test]
fn display_name_derives_from_name_when_title_absent() {
let s: OfficialServer = serde_json::from_value(json!({
"name": "io.github.user/my-cool-server",
}))
.unwrap();
assert_eq!(s.display_name(), "my cool server");
}
#[test]
fn display_name_derives_from_name_when_title_blank() {
let s: OfficialServer = serde_json::from_value(json!({
"name": "io.github.user/server-bar",
"title": " ",
}))
.unwrap();
assert_eq!(s.display_name(), "server bar");
}
#[test]
fn display_name_falls_back_to_last_dot_segment_when_no_slash() {
let s: OfficialServer = serde_json::from_value(json!({
"name": "com.example.my_server",
}))
.unwrap();
assert_eq!(s.display_name(), "my server");
}
#[test]
fn display_name_returns_raw_name_when_no_slash_or_dot() {
let s: OfficialServer = serde_json::from_value(json!({
"name": "standalone",
}))
.unwrap();
assert_eq!(s.display_name(), "standalone");
}
// ── title flows through to summary and detail ───────────────────────────
#[test]
fn into_summary_carries_title_as_display_name() {
let s: OfficialServer = serde_json::from_value(json!({
"name": "io.github.notion/notion-mcp",
"title": "Notion MCP",
"description": "Notion integration",
"iconUrl": "https://example.com/icon.png",
"remotes": [{ "url": "https://notion.mcp.example.com" }],
}))
.unwrap();
let sum = s.into_summary();
assert_eq!(sum.display_name, "Notion MCP");
assert_eq!(sum.qualified_name, "io.github.notion/notion-mcp");
assert_eq!(sum.description.as_deref(), Some("Notion integration"));
assert_eq!(
sum.icon_url.as_deref(),
Some("https://example.com/icon.png")
);
assert!(sum.is_deployed, "server with remotes should be deployed");
assert_eq!(sum.source, SOURCE_MCP_OFFICIAL);
}
#[test]
fn into_detail_carries_title_as_display_name() {
let s: OfficialServer = serde_json::from_value(json!({
"name": "io.github.slack/slack-mcp",
"title": "Slack MCP Server",
"remotes": [{ "url": "https://slack.mcp.example.com" }],
}))
.unwrap();
let detail = s.into_detail();
assert_eq!(detail.display_name, "Slack MCP Server");
assert_eq!(detail.qualified_name, "io.github.slack/slack-mcp");
assert_eq!(detail.source, SOURCE_MCP_OFFICIAL);
assert_eq!(detail.connections.len(), 1);
assert_eq!(detail.connections[0].r#type, "http");
}
// ── realistic multi-server list response with mixed title presence ───────
#[test]
fn list_response_parses_servers_with_proper_titles() {
let raw = json!({
"servers": [
{
"server": {
"name": "io.github.modelcontextprotocol/server-filesystem",
"title": "Filesystem MCP Server",
"description": "Secure file operations",
"packages": [{ "registryType": "npm", "identifier": "@modelcontextprotocol/server-filesystem" }],
},
"_meta": { "io.modelcontextprotocol.registry/official": { "status": "active" } }
},
{
"server": {
"name": "io.github.github/github-mcp-server",
"title": "GitHub MCP Server",
"description": "GitHub API integration",
"remotes": [{ "url": "https://github-mcp.example.com" }],
},
"_meta": {}
},
{
"server": {
"name": "io.github.someuser/untitled-tool",
"description": "A server without a title field",
},
"_meta": {}
}
],
"metadata": { "nextCursor": "cursor-abc", "count": 3 }
});
let parsed: OfficialListResponse = serde_json::from_value(raw).unwrap();
assert_eq!(parsed.next_cursor(), Some("cursor-abc"));
let summaries = parsed.into_summaries();
assert_eq!(summaries.len(), 3);
assert_eq!(summaries[0].display_name, "Filesystem MCP Server");
assert_eq!(
summaries[0].qualified_name,
"io.github.modelcontextprotocol/server-filesystem"
);
assert!(
!summaries[0].is_deployed,
"packages-only server is not deployed"
);
assert_eq!(summaries[1].display_name, "GitHub MCP Server");
assert!(summaries[1].is_deployed, "server with remotes is deployed");
// No title → fallback derived from name after last `/`
assert_eq!(summaries[2].display_name, "untitled tool");
assert_eq!(
summaries[2].qualified_name,
"io.github.someuser/untitled-tool"
);
}
/// Duplicate `name` values in the same response page are deduped by
/// `into_summaries` — only the first occurrence survives.
#[test]
fn list_response_deduplicates_by_name() {
let raw = json!({
"servers": [
{ "server": { "name": "io.github.x/dup", "title": "First" } },
{ "server": { "name": "io.github.x/dup", "title": "Second" } },
]
});
let parsed: OfficialListResponse = serde_json::from_value(raw).unwrap();
let summaries = parsed.into_summaries();
assert_eq!(summaries.len(), 1);
assert_eq!(summaries[0].display_name, "First");
}
#[test]
fn into_detail_populates_example_config_for_packages() {
let server: OfficialServer = serde_json::from_value(json!({
+155 -15
View File
@@ -209,18 +209,23 @@ pub struct ConnStatus {
// ── Smithery registry DTOs ───────────────────────────────────────────────────
/// Summary record returned by `GET /servers`.
///
/// Field aliases accept both camelCase (Smithery wire format) and snake_case
/// (internal / RPC) on deserialization; serialization always produces
/// snake_case so the frontend receives the field names it expects.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SmitheryServerSummary {
#[serde(alias = "qualifiedName")]
pub qualified_name: String,
#[serde(alias = "displayName")]
pub display_name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
#[serde(default, alias = "iconUrl")]
pub icon_url: Option<String>,
#[serde(default)]
#[serde(default, alias = "useCount")]
pub use_count: u64,
#[serde(default)]
#[serde(default, alias = "isDeployed")]
pub is_deployed: bool,
/// Upstream registry id (`"smithery"` | `"mcp_official"`). Always set
/// by the dispatcher in `super::registries` so the frontend can attribute
@@ -235,13 +240,14 @@ pub struct SmitheryServerSummary {
/// Detail record returned by `GET /servers/{qualifiedName}`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SmitheryServerDetail {
#[serde(alias = "qualifiedName")]
pub qualified_name: String,
#[serde(alias = "displayName")]
pub display_name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
#[serde(default, alias = "iconUrl")]
pub icon_url: Option<String>,
#[serde(default)]
pub connections: Vec<SmitheryConnection>,
@@ -254,15 +260,14 @@ pub struct SmitheryServerDetail {
/// One connection type listed on a server detail.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SmitheryConnection {
/// `"stdio"` or `"http"`.
pub r#type: String,
#[serde(default)]
#[serde(default, alias = "deploymentUrl")]
pub deployment_url: Option<String>,
#[serde(default)]
#[serde(default, alias = "configSchema")]
pub config_schema: Option<Value>,
#[serde(default)]
#[serde(default, alias = "exampleConfig")]
pub example_config: Option<Value>,
#[serde(default)]
pub published: bool,
@@ -272,15 +277,14 @@ pub struct SmitheryConnection {
/// Pagination wrapper from Smithery's `/servers` endpoint.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct SmitheryPagination {
#[serde(default)]
#[serde(default, alias = "currentPage")]
pub current_page: u32,
#[serde(default)]
#[serde(default, alias = "pageSize")]
pub page_size: u32,
#[serde(default)]
#[serde(default, alias = "totalPages")]
pub total_pages: u32,
#[serde(default)]
#[serde(default, alias = "totalCount")]
pub total_count: u64,
}
@@ -457,6 +461,142 @@ mod tests {
);
}
/// Smithery API sends camelCase; the official adapter builds snake_case
/// in `into_summary()`. Both must deserialize into the same struct.
#[test]
fn smithery_summary_deserializes_from_snake_case() {
let raw = json!({
"qualified_name": "@test/snake",
"display_name": "Snake Test",
"icon_url": "https://example.com/icon.png",
"use_count": 42,
"is_deployed": true,
});
let s: SmitheryServerSummary = serde_json::from_value(raw).unwrap();
assert_eq!(s.qualified_name, "@test/snake");
assert_eq!(s.display_name, "Snake Test");
assert_eq!(s.icon_url.as_deref(), Some("https://example.com/icon.png"));
assert_eq!(s.use_count, 42);
assert!(s.is_deployed);
}
/// RPC responses to the frontend must use snake_case field names.
/// This pins the serialization format so a future serde annotation
/// change doesn't silently break the frontend.
#[test]
fn smithery_summary_serializes_as_snake_case() {
let s = SmitheryServerSummary {
qualified_name: "@test/ser".to_string(),
display_name: "Ser Test".to_string(),
description: Some("desc".to_string()),
icon_url: Some("https://example.com/i.png".to_string()),
use_count: 10,
is_deployed: true,
source: "mcp_official".to_string(),
extra: Default::default(),
};
let v = serde_json::to_value(&s).unwrap();
assert!(
v.get("qualified_name").is_some(),
"expected snake_case qualified_name"
);
assert!(
v.get("display_name").is_some(),
"expected snake_case display_name"
);
assert!(v.get("icon_url").is_some(), "expected snake_case icon_url");
assert!(
v.get("use_count").is_some(),
"expected snake_case use_count"
);
assert!(
v.get("is_deployed").is_some(),
"expected snake_case is_deployed"
);
// Must NOT have camelCase keys
assert!(
v.get("qualifiedName").is_none(),
"must not serialize as camelCase"
);
assert!(
v.get("displayName").is_none(),
"must not serialize as camelCase"
);
}
/// Same snake_case serialization pin for SmitheryServerDetail.
#[test]
fn smithery_detail_serializes_as_snake_case() {
let d = SmitheryServerDetail {
qualified_name: "@test/d".to_string(),
display_name: "Detail".to_string(),
description: None,
icon_url: None,
connections: vec![],
source: "smithery".to_string(),
extra: Default::default(),
};
let v = serde_json::to_value(&d).unwrap();
assert!(
v.get("qualified_name").is_some(),
"expected snake_case qualified_name"
);
assert!(
v.get("display_name").is_some(),
"expected snake_case display_name"
);
assert!(
v.get("qualifiedName").is_none(),
"must not serialize as camelCase"
);
}
/// SmitheryConnection must serialize with snake_case for the frontend.
#[test]
fn smithery_connection_serializes_as_snake_case() {
let c = SmitheryConnection {
r#type: "stdio".to_string(),
deployment_url: Some("https://x.com".to_string()),
config_schema: None,
example_config: Some(json!({"command": "npx"})),
published: true,
extra: Default::default(),
};
let v = serde_json::to_value(&c).unwrap();
assert!(
v.get("deployment_url").is_some(),
"expected snake_case deployment_url"
);
assert!(
v.get("config_schema").is_some(),
"expected snake_case config_schema"
);
assert!(
v.get("example_config").is_some(),
"expected snake_case example_config"
);
assert!(
v.get("deploymentUrl").is_none(),
"must not serialize as camelCase"
);
}
/// SmitheryConnection must also deserialize from Smithery's camelCase wire format.
#[test]
fn smithery_connection_deserializes_from_camel_case() {
let raw = json!({
"type": "stdio",
"deploymentUrl": "https://x.com",
"configSchema": { "properties": {} },
"exampleConfig": { "command": "npx" },
"published": true,
});
let c: SmitheryConnection = serde_json::from_value(raw).unwrap();
assert_eq!(c.deployment_url.as_deref(), Some("https://x.com"));
assert!(c.config_schema.is_some());
assert!(c.example_config.is_some());
}
#[test]
fn conn_status_status_field_serializes_lowercase() {
let s = ConnStatus {