mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(mcp-registry): rich install screen and table UI redesign (#3492)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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': 'اتصال',
|
||||
|
||||
@@ -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 ব্যবহার করুন',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}.',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user