mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: M3gA-Mind <megamind@mahadao.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
M3gA-Mind
parent
7bf18562a1
commit
07140e9c0b
@@ -34,8 +34,10 @@ export function clearConfigChat(qualifiedName: string): void {
|
|||||||
interface ConfigAssistantPanelProps {
|
interface ConfigAssistantPanelProps {
|
||||||
qualifiedName: string;
|
qualifiedName: string;
|
||||||
onApplySuggestedEnv?: (env: Record<string, string>) => void;
|
onApplySuggestedEnv?: (env: Record<string, string>) => void;
|
||||||
/** A fixed, server-specific prompt auto-sent once on mount (so the user gets
|
/** A fixed, server-specific prompt offered as a one-click action in the empty
|
||||||
* guidance with a single click instead of having to know what to ask). */
|
* state. It is NOT auto-sent: firing the LLM research call on open made the
|
||||||
|
* help panel block for seconds before showing anything (#4272). The user taps
|
||||||
|
* the suggestion when they actually want guidance. */
|
||||||
autoPrompt?: string;
|
autoPrompt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,16 +113,6 @@ const ConfigAssistantPanel = ({
|
|||||||
chatCache.set(qualifiedName, messages);
|
chatCache.set(qualifiedName, messages);
|
||||||
}, [qualifiedName, messages]);
|
}, [qualifiedName, messages]);
|
||||||
|
|
||||||
// Auto-run the fixed prompt once — but only for a fresh chat. If we restored
|
|
||||||
// an existing conversation from the cache, don't re-ask.
|
|
||||||
const autoSent = useRef(false);
|
|
||||||
useEffect(() => {
|
|
||||||
if (autoPrompt && !autoSent.current && messages.length === 0) {
|
|
||||||
autoSent.current = true;
|
|
||||||
void send(autoPrompt);
|
|
||||||
}
|
|
||||||
}, [autoPrompt, send, messages.length]);
|
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
@@ -136,9 +128,18 @@ const ConfigAssistantPanel = ({
|
|||||||
{/* Message list */}
|
{/* Message list */}
|
||||||
<div className="flex-1 overflow-y-auto space-y-2 min-h-0 rounded-lg border border-line-subtle p-2">
|
<div className="flex-1 overflow-y-auto space-y-2 min-h-0 rounded-lg border border-line-subtle p-2">
|
||||||
{messages.length === 0 && (
|
{messages.length === 0 && (
|
||||||
<p className="text-xs text-content-faint py-2 text-center">
|
<div className="py-2 text-center space-y-2">
|
||||||
{t('mcp.configAssistant.empty')}
|
<p className="text-xs text-content-faint">{t('mcp.configAssistant.empty')}</p>
|
||||||
</p>
|
{autoPrompt && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
disabled={sending}
|
||||||
|
onClick={() => void send(autoPrompt)}>
|
||||||
|
{t('mcp.configAssistant.autoPromptCta')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{messages.map((msg, idx) => (
|
{messages.map((msg, idx) => (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ describe('ConfigHelpModal', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockConfigAssist.mockReset();
|
mockConfigAssist.mockReset();
|
||||||
// The embedded ConfigAssistantPanel caches chat history per qualified_name at
|
// The embedded ConfigAssistantPanel caches chat history per qualified_name at
|
||||||
// module scope; clear it so each test starts with a fresh chat and the
|
// module scope; clear it so each test starts with a fresh chat (and the
|
||||||
// auto-prompt fires again (a restored, non-empty chat suppresses the auto-run).
|
// one-click setup-help action is offered again).
|
||||||
clearConfigChat('acme/test-server');
|
clearConfigChat('acme/test-server');
|
||||||
// The auto-sent prompt resolves so the auto-run doesn't surface as an error.
|
// The on-demand prompt resolves when the user triggers it.
|
||||||
mockConfigAssist.mockResolvedValue({ reply: 'Get a token from the dashboard.' });
|
mockConfigAssist.mockResolvedValue({ reply: 'Get a token from the dashboard.' });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -35,9 +35,13 @@ describe('ConfigHelpModal', () => {
|
|||||||
expect(screen.getByRole('heading', { name: 'Help & configure' })).toBeInTheDocument();
|
expect(screen.getByRole('heading', { name: 'Help & configure' })).toBeInTheDocument();
|
||||||
// Embedded ConfigAssistantPanel renders its input.
|
// Embedded ConfigAssistantPanel renders its input.
|
||||||
expect(screen.getByPlaceholderText(/ask a question/i)).toBeInTheDocument();
|
expect(screen.getByPlaceholderText(/ask a question/i)).toBeInTheDocument();
|
||||||
|
// The help opens instantly — no blocking LLM call on open (#4272). The
|
||||||
|
// research is offered as a one-click action instead.
|
||||||
|
expect(mockConfigAssist).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByRole('button', { name: 'Get step-by-step setup help' })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('auto-runs a server-specific prompt naming the display name and qualified name', async () => {
|
it('runs a server-specific prompt on demand, naming the display name and qualified name', async () => {
|
||||||
render(
|
render(
|
||||||
<ConfigHelpModal
|
<ConfigHelpModal
|
||||||
qualifiedName="acme/test-server"
|
qualifiedName="acme/test-server"
|
||||||
@@ -46,19 +50,21 @@ describe('ConfigHelpModal', () => {
|
|||||||
onClose={() => {}}
|
onClose={() => {}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
// Nothing fires until the user asks for help.
|
||||||
|
expect(mockConfigAssist).not.toHaveBeenCalled();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Get step-by-step setup help' }));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockConfigAssist).toHaveBeenCalledTimes(1);
|
expect(mockConfigAssist).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
const [{ qualified_name, user_message }] = mockConfigAssist.mock.calls[0];
|
const [{ qualified_name, user_message }] = mockConfigAssist.mock.calls[0];
|
||||||
expect(qualified_name).toBe('acme/test-server');
|
expect(qualified_name).toBe('acme/test-server');
|
||||||
// The auto prompt embeds both the friendly name and the qualified name and
|
// The prompt embeds the friendly name, the qualified name, and description.
|
||||||
// the description.
|
|
||||||
expect(user_message).toContain('Test Server');
|
expect(user_message).toContain('Test Server');
|
||||||
expect(user_message).toContain('acme/test-server');
|
expect(user_message).toContain('acme/test-server');
|
||||||
expect(user_message).toContain('A test MCP server');
|
expect(user_message).toContain('A test MCP server');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('builds an auto prompt that omits the description sentence when none is given', async () => {
|
it('builds an on-demand prompt that omits the description sentence when none is given', async () => {
|
||||||
render(
|
render(
|
||||||
<ConfigHelpModal
|
<ConfigHelpModal
|
||||||
qualifiedName="acme/test-server"
|
qualifiedName="acme/test-server"
|
||||||
@@ -66,6 +72,7 @@ describe('ConfigHelpModal', () => {
|
|||||||
onClose={() => {}}
|
onClose={() => {}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Get step-by-step setup help' }));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockConfigAssist).toHaveBeenCalledTimes(1);
|
expect(mockConfigAssist).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
@@ -116,7 +123,9 @@ describe('ConfigHelpModal', () => {
|
|||||||
onApplySuggestedEnv={onApply}
|
onApplySuggestedEnv={onApply}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
// The auto-run reply carries suggested_env, so the Apply button appears.
|
// Trigger the on-demand help; the reply carries suggested_env, so the Apply
|
||||||
|
// button appears.
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Get step-by-step setup help' }));
|
||||||
const applyBtn = await screen.findByRole('button', { name: 'Apply suggested values' });
|
const applyBtn = await screen.findByRole('button', { name: 'Apply suggested values' });
|
||||||
fireEvent.click(applyBtn);
|
fireEvent.click(applyBtn);
|
||||||
expect(onApply).toHaveBeenCalledWith({ API_KEY: 'abc' });
|
expect(onApply).toHaveBeenCalledWith({ API_KEY: 'abc' });
|
||||||
|
|||||||
@@ -62,8 +62,30 @@ describe('InstallDialog', () => {
|
|||||||
expect(screen.getByText('Test Server')).toBeInTheDocument();
|
expect(screen.getByText('Test Server')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
expect(screen.getByText('A test server')).toBeInTheDocument();
|
expect(screen.getByText('A test server')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Requires configuration')).toBeInTheDocument();
|
// Transport badge uses the same Stdio/Hosted vocabulary as the catalog list
|
||||||
expect(screen.getByText('Runs locally')).toBeInTheDocument();
|
// (the old separate "Cloud hosted"/"Requires configuration" pills were
|
||||||
|
// dropped — the env-vars section below already conveys configuration).
|
||||||
|
// Derived from the connection type (stdio here), NOT the detail DTO's
|
||||||
|
// absent `is_deployed`.
|
||||||
|
expect(screen.getByText('Stdio')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('labels a server with an http connection as Hosted', async () => {
|
||||||
|
mockRegistryGet.mockResolvedValue({
|
||||||
|
...DETAIL,
|
||||||
|
qualified_name: 'acme/hosted-server',
|
||||||
|
display_name: 'Hosted Server',
|
||||||
|
connections: [{ type: 'http', deployment_url: 'https://x.example/mcp', published: true }],
|
||||||
|
});
|
||||||
|
render(
|
||||||
|
<InstallDialog qualifiedName="acme/hosted-server" onSuccess={() => {}} onCancel={() => {}} />
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Hosted Server')).toBeInTheDocument());
|
||||||
|
// The detail DTO carries no `is_deployed`; the badge must derive from the
|
||||||
|
// http connection (this regressed to always-"Stdio" before the fix).
|
||||||
|
expect(screen.getByText('Hosted')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Stdio')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows env key preview badges on detail step', async () => {
|
it('shows env key preview badges on detail step', async () => {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { useT } from '../../../lib/i18n/I18nContext';
|
|||||||
import { mcpClientsApi } from '../../../services/api/mcpClientsApi';
|
import { mcpClientsApi } from '../../../services/api/mcpClientsApi';
|
||||||
import Button from '../../ui/Button';
|
import Button from '../../ui/Button';
|
||||||
import { deriveAuthor } from './McpServerCard';
|
import { deriveAuthor } from './McpServerCard';
|
||||||
import type { InstalledServer, SmitheryConnection, SmitheryServerDetail } from './types';
|
import type { InstalledServer, SmitheryServerDetail } from './types';
|
||||||
|
|
||||||
const log = debug('mcp-clients:install');
|
const log = debug('mcp-clients:install');
|
||||||
|
|
||||||
@@ -30,13 +30,6 @@ interface InstallDialogProps {
|
|||||||
onCancel: () => void;
|
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 {
|
function formatUseCount(count: number): string {
|
||||||
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
|
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
|
||||||
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
|
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
|
||||||
@@ -198,7 +191,10 @@ const InstallDialog = ({ qualifiedName, prefillEnv, onSuccess, onCancel }: Insta
|
|||||||
if (!detail) return null;
|
if (!detail) return null;
|
||||||
|
|
||||||
const author = deriveAuthor(qualifiedName);
|
const author = deriveAuthor(qualifiedName);
|
||||||
const transport = pickTransportLabel(detail.connections);
|
// The detail DTO doesn't carry `is_deployed`, so derive transport from the
|
||||||
|
// connection types instead (an `http` connection ⇒ hosted, else stdio) —
|
||||||
|
// otherwise hosted-only servers are always mislabeled "Stdio".
|
||||||
|
const isHosted = (detail.connections ?? []).some(c => c.type === 'http');
|
||||||
|
|
||||||
// ── Step 1: Detail overview ──────────────────────────────────────────────
|
// ── Step 1: Detail overview ──────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -236,30 +232,19 @@ const InstallDialog = ({ qualifiedName, prefillEnv, onSuccess, onCancel }: Insta
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats badges */}
|
{/* Stats badges — same Hosted/Stdio vocabulary as the catalog list (no
|
||||||
|
separate "Cloud hosted"/"Requires configuration" pills: the
|
||||||
|
transport badge and the "Required environment variables" section
|
||||||
|
below already convey both). */}
|
||||||
<div className="flex flex-wrap gap-2">
|
<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-surface-subtle text-content-secondary">
|
||||||
<span className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium bg-surface-subtle text-content-secondary">
|
{isHosted ? t('mcp.tab.transport.hosted') : t('mcp.tab.transport.local')}
|
||||||
{transport === 'stdio'
|
</span>
|
||||||
? t('mcp.install.transportLocal')
|
|
||||||
: t('mcp.install.transportRemote')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{detail.use_count != null && detail.use_count > 0 && (
|
{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-surface-subtle text-content-secondary">
|
<span className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium bg-surface-subtle text-content-secondary">
|
||||||
{t('mcp.install.useCount').replace('{count}', formatUseCount(detail.use_count))}
|
{t('mcp.install.useCount').replace('{count}', formatUseCount(detail.use_count))}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* navigation to detail/install views, install success, uninstall, and
|
* navigation to detail/install views, install success, uninstall, and
|
||||||
* status polling.
|
* status polling.
|
||||||
*/
|
*/
|
||||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import McpServersTab from './McpServersTab';
|
import McpServersTab from './McpServersTab';
|
||||||
@@ -20,6 +20,11 @@ const mockSetEnabled = vi.fn();
|
|||||||
const mockRegistryGet = vi.fn();
|
const mockRegistryGet = vi.fn();
|
||||||
const mockRegistrySearch = vi.fn();
|
const mockRegistrySearch = vi.fn();
|
||||||
const mockConfigAssist = vi.fn();
|
const mockConfigAssist = vi.fn();
|
||||||
|
const mockOpenUrl = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('../../../utils/openUrl', () => ({
|
||||||
|
openUrl: (...args: unknown[]) => mockOpenUrl(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../../../services/api/mcpClientsApi', () => ({
|
vi.mock('../../../services/api/mcpClientsApi', () => ({
|
||||||
mcpClientsApi: {
|
mcpClientsApi: {
|
||||||
@@ -107,6 +112,8 @@ describe('McpServersTab', () => {
|
|||||||
mockRegistryGet.mockReset();
|
mockRegistryGet.mockReset();
|
||||||
mockRegistrySearch.mockReset();
|
mockRegistrySearch.mockReset();
|
||||||
mockRegistrySearch.mockResolvedValue({ servers: [], page: 1, total_pages: 1 });
|
mockRegistrySearch.mockResolvedValue({ servers: [], page: 1, total_pages: 1 });
|
||||||
|
mockOpenUrl.mockReset();
|
||||||
|
mockOpenUrl.mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -325,7 +332,7 @@ describe('McpServersTab', () => {
|
|||||||
expect(screen.getAllByText('Server B')).toHaveLength(1);
|
expect(screen.getAllByText('Server B')).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('distinguishes look-alike registry rows by slug and source badge', async () => {
|
it('distinguishes look-alike registry rows by slug', async () => {
|
||||||
mockInstalledList.mockResolvedValue([]);
|
mockInstalledList.mockResolvedValue([]);
|
||||||
mockStatus.mockResolvedValue([]);
|
mockStatus.mockResolvedValue([]);
|
||||||
mockRegistrySearch.mockResolvedValue({
|
mockRegistrySearch.mockResolvedValue({
|
||||||
@@ -352,11 +359,15 @@ describe('McpServersTab', () => {
|
|||||||
|
|
||||||
// Both rows share the display name "gmail"...
|
// Both rows share the display name "gmail"...
|
||||||
await waitFor(() => expect(screen.getAllByText('gmail')).toHaveLength(2));
|
await waitFor(() => expect(screen.getAllByText('gmail')).toHaveLength(2));
|
||||||
// ...but the unique slug and the registry-source badge tell them apart.
|
// ...but the unique slug tells them apart. The registry-source pill was
|
||||||
|
// removed: it labelled every official-registry row "Official", which falsely
|
||||||
|
// vouched for un-vetted community servers. Only the vendor `official` badge
|
||||||
|
// remains, and only on the row flagged official.
|
||||||
expect(screen.getByText('waystation/gmail')).toBeInTheDocument();
|
expect(screen.getByText('waystation/gmail')).toBeInTheDocument();
|
||||||
expect(screen.getByText('mintmcp/gmail')).toBeInTheDocument();
|
expect(screen.getByText('mintmcp/gmail')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Official')).toBeInTheDocument();
|
// Both fixture rows are flagged official → two vendor badges, no source pill.
|
||||||
expect(screen.getByText('Smithery')).toBeInTheDocument();
|
expect(screen.getAllByText(/Official/)).toHaveLength(2);
|
||||||
|
expect(screen.queryByText('Smithery')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the registry as one list (no auth-method grouping) in registry order', async () => {
|
it('renders the registry as one list (no auth-method grouping) in registry order', async () => {
|
||||||
@@ -381,11 +392,90 @@ describe('McpServersTab', () => {
|
|||||||
expect(screen.queryByText('Browser sign-in')).not.toBeInTheDocument();
|
expect(screen.queryByText('Browser sign-in')).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText('Token / API key')).not.toBeInTheDocument();
|
expect(screen.queryByText('Token / API key')).not.toBeInTheDocument();
|
||||||
// Rows keep their registry order (relevance), regardless of transport.
|
// Rows keep their registry order (relevance), regardless of transport.
|
||||||
const tableText = screen.getByRole('table').textContent ?? '';
|
const table = screen.getByRole('table');
|
||||||
|
const tableText = table.textContent ?? '';
|
||||||
expect(tableText.indexOf('Local One')).toBeLessThan(tableText.indexOf('Hosted One'));
|
expect(tableText.indexOf('Local One')).toBeLessThan(tableText.indexOf('Hosted One'));
|
||||||
// Per-row Hosted/Local hint badges still render.
|
// Per-row transport badges render (Stdio vs Hosted). Scope to the table —
|
||||||
expect(screen.getByText('Hosted')).toBeInTheDocument();
|
// "Stdio"/"Hosted" also appear as the transport filter chips above it.
|
||||||
expect(screen.getByText('Local')).toBeInTheDocument();
|
expect(within(table).getByText('Hosted')).toBeInTheDocument();
|
||||||
|
expect(within(table).getByText('Stdio')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders clickable website + repo links that open externally without installing', async () => {
|
||||||
|
mockInstalledList.mockResolvedValue([]);
|
||||||
|
mockStatus.mockResolvedValue([]);
|
||||||
|
mockRegistrySearch.mockResolvedValue({
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
qualified_name: 'io.github.acme/cool-mcp',
|
||||||
|
display_name: 'Cool MCP',
|
||||||
|
is_deployed: true,
|
||||||
|
website_url: 'https://acme.example',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
page: 1,
|
||||||
|
total_pages: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
await renderAndWaitForLoad();
|
||||||
|
vi.useRealTimers();
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Cool MCP'));
|
||||||
|
|
||||||
|
// Website link opens the declared site; repo link is derived from the
|
||||||
|
// io.github.<user>/<repo> slug. Neither should trigger the row's install nav.
|
||||||
|
fireEvent.click(screen.getByText('Website'));
|
||||||
|
expect(mockOpenUrl).toHaveBeenCalledWith('https://acme.example');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Repository'));
|
||||||
|
expect(mockOpenUrl).toHaveBeenCalledWith('https://github.com/acme/cool-mcp');
|
||||||
|
|
||||||
|
// Still on the catalog list — clicking a link did not navigate to install.
|
||||||
|
expect(screen.getByText('Cool MCP')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Install Cool MCP')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-queries the core with a transport filter when the Stdio/Hosted pills are toggled', async () => {
|
||||||
|
mockInstalledList.mockResolvedValue([]);
|
||||||
|
mockStatus.mockResolvedValue([]);
|
||||||
|
// Transport filtering now happens in the core: the mock answers per the
|
||||||
|
// `transport` param it receives, and toggling a pill re-queries.
|
||||||
|
const STDIO = { qualified_name: 'a/stdio-srv', display_name: 'Stdio Srv', is_deployed: false };
|
||||||
|
const HOSTED = {
|
||||||
|
qualified_name: 'b/hosted-srv',
|
||||||
|
display_name: 'Hosted Srv',
|
||||||
|
is_deployed: true,
|
||||||
|
};
|
||||||
|
mockRegistrySearch.mockImplementation((params: { transport?: string }) => {
|
||||||
|
const servers =
|
||||||
|
params.transport === 'stdio'
|
||||||
|
? [STDIO]
|
||||||
|
: params.transport === 'hosted'
|
||||||
|
? [HOSTED]
|
||||||
|
: [STDIO, HOSTED];
|
||||||
|
return Promise.resolve({ servers, page: 1, total_pages: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await renderAndWaitForLoad();
|
||||||
|
vi.useRealTimers();
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Hosted Srv'));
|
||||||
|
expect(screen.getByText('Stdio Srv')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Toggle "Stdio" → core is re-queried with transport:'stdio'; hosted drops.
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Stdio' }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockRegistrySearch).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ transport: 'stdio' })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(screen.queryByText('Hosted Srv')).not.toBeInTheDocument());
|
||||||
|
expect(screen.getByText('Stdio Srv')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Switch to "Hosted" → transport:'hosted'; stdio drops.
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Hosted' }));
|
||||||
|
await waitFor(() => expect(screen.getByText('Hosted Srv')).toBeInTheDocument());
|
||||||
|
expect(screen.queryByText('Stdio Srv')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders every returned row and badges only the official one, with no Show all toggle', async () => {
|
it('renders every returned row and badges only the official one, with no Show all toggle', async () => {
|
||||||
@@ -426,6 +516,54 @@ describe('McpServersTab', () => {
|
|||||||
expect(screen.queryByText('Verified only')).not.toBeInTheDocument();
|
expect(screen.queryByText('Verified only')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows a registry error state with retry when the catalog fetch fails', async () => {
|
||||||
|
mockInstalledList.mockResolvedValue([]);
|
||||||
|
mockStatus.mockResolvedValue([]);
|
||||||
|
// First catalog fetch fails → error state; retry succeeds → rows render.
|
||||||
|
mockRegistrySearch.mockRejectedValueOnce(new Error('registry down'));
|
||||||
|
mockRegistrySearch.mockResolvedValue({
|
||||||
|
servers: [{ qualified_name: 'a/srv', display_name: 'Recovered Srv', is_deployed: false }],
|
||||||
|
page: 1,
|
||||||
|
total_pages: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
await renderAndWaitForLoad();
|
||||||
|
vi.useRealTimers();
|
||||||
|
|
||||||
|
// Error surfaces instead of a silent empty state.
|
||||||
|
const errorBox = await screen.findByTestId('mcp-catalog-error');
|
||||||
|
expect(errorBox).toHaveTextContent('Failed to load catalog');
|
||||||
|
expect(screen.queryByTestId('mcp-catalog-empty')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// Retry re-fetches and renders the recovered catalog.
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
|
||||||
|
await waitFor(() => expect(screen.getByText('Recovered Srv')).toBeInTheDocument());
|
||||||
|
expect(screen.queryByTestId('mcp-catalog-error')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces the health toolbar and reconnects error-state servers via Retry all', async () => {
|
||||||
|
mockInstalledList.mockResolvedValue(SERVERS);
|
||||||
|
mockStatus.mockResolvedValue([
|
||||||
|
{
|
||||||
|
server_id: 'srv-1',
|
||||||
|
qualified_name: 'acme/fs-server',
|
||||||
|
display_name: 'File Server',
|
||||||
|
status: 'error' as const,
|
||||||
|
tool_count: 0,
|
||||||
|
last_error: 'connect failed',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await renderAndWaitForLoad();
|
||||||
|
vi.useRealTimers();
|
||||||
|
|
||||||
|
// The toolbar (previously never rendered in production) now appears, and its
|
||||||
|
// "Retry all" affordance reconnects the errored server.
|
||||||
|
const retry = await screen.findByRole('button', { name: /retry all/i });
|
||||||
|
fireEvent.click(retry);
|
||||||
|
await waitFor(() => expect(mockConnect).toHaveBeenCalledWith('srv-1'));
|
||||||
|
});
|
||||||
|
|
||||||
it('navigates to install view when a registry server row is clicked', async () => {
|
it('navigates to install view when a registry server row is clicked', async () => {
|
||||||
mockInstalledList.mockResolvedValue([]);
|
mockInstalledList.mockResolvedValue([]);
|
||||||
mockStatus.mockResolvedValue([]);
|
mockStatus.mockResolvedValue([]);
|
||||||
|
|||||||
@@ -6,14 +6,16 @@
|
|||||||
* between "All", "Installed", and "Registry" views.
|
* between "All", "Installed", and "Registry" views.
|
||||||
*/
|
*/
|
||||||
import debug from 'debug';
|
import debug from 'debug';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { useT } from '../../../lib/i18n/I18nContext';
|
import { useT } from '../../../lib/i18n/I18nContext';
|
||||||
import { mcpClientsApi } from '../../../services/api/mcpClientsApi';
|
import { mcpClientsApi } from '../../../services/api/mcpClientsApi';
|
||||||
|
import { openUrl } from '../../../utils/openUrl';
|
||||||
import ChipTabs from '../../layout/ChipTabs';
|
import ChipTabs from '../../layout/ChipTabs';
|
||||||
import Button from '../../ui/Button';
|
import Button from '../../ui/Button';
|
||||||
import InstallDialog from './InstallDialog';
|
import InstallDialog from './InstallDialog';
|
||||||
import InstalledServerDetail from './InstalledServerDetail';
|
import InstalledServerDetail from './InstalledServerDetail';
|
||||||
|
import McpConnectionHealthToolbar from './McpConnectionHealthToolbar';
|
||||||
import McpInventoryPanel from './McpInventoryPanel';
|
import McpInventoryPanel from './McpInventoryPanel';
|
||||||
import { deriveAuthor } from './McpServerCard';
|
import { deriveAuthor } from './McpServerCard';
|
||||||
import type { ConnStatus, InstalledServer, ServerStatus, SmitheryServer } from './types';
|
import type { ConnStatus, InstalledServer, ServerStatus, SmitheryServer } from './types';
|
||||||
@@ -73,56 +75,181 @@ const STATUS_DOT: Record<ServerStatus, string> = {
|
|||||||
disabled: 'bg-surface-strong',
|
disabled: 'bg-surface-strong',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Maps an upstream registry id to its i18n label key. The official
|
/** Transport classification a catalog row can be filtered by. */
|
||||||
// modelcontextprotocol.io registry is highlighted (sage) over Smithery so the
|
type Transport = 'hosted' | 'stdio';
|
||||||
// authoritative source reads as the "top" one.
|
|
||||||
const SOURCE_LABEL_KEY: Record<string, string> = {
|
|
||||||
mcp_official: 'mcp.tab.source.official',
|
|
||||||
smithery: 'mcp.tab.source.smithery',
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Pill attributing a catalog row to the registry it came from. */
|
|
||||||
const SourceBadge = ({ source }: { source?: string }) => {
|
|
||||||
const { t } = useT();
|
|
||||||
const labelKey = source ? SOURCE_LABEL_KEY[source] : undefined;
|
|
||||||
if (!labelKey) {
|
|
||||||
return <span className="text-xs text-stone-400 dark:text-neutral-600">—</span>;
|
|
||||||
}
|
|
||||||
const isOfficial = source === 'mcp_official';
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${
|
|
||||||
isOfficial
|
|
||||||
? 'bg-sage-100 text-sage-700 dark:bg-sage-500/15 dark:text-sage-300'
|
|
||||||
: 'bg-stone-100 text-stone-600 dark:bg-neutral-800 dark:text-neutral-300'
|
|
||||||
}`}>
|
|
||||||
{t(labelKey)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tiny hint on how a catalog row is reached: a hosted server (has an HTTP
|
* Classify a catalog row by how it runs: `hosted` = reachable over an HTTP
|
||||||
* endpoint) can offer browser sign-in; a local server is run on-device and
|
* endpoint (cloud-run); `stdio` = installed and run on-device as a subprocess.
|
||||||
* configured with a pasted token. This mirrors the `is_deployed` sort that
|
* `is_deployed` is set by the registry adapter when the server exposes a remote.
|
||||||
* floats hosted servers to the top.
|
|
||||||
*/
|
*/
|
||||||
const TransportHintBadge = ({ deployed }: { deployed?: boolean }) => {
|
const transportOf = (server: SmitheryServer): Transport =>
|
||||||
|
server.is_deployed ? 'hosted' : 'stdio';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a browsable source-repository URL from the registry slug. The official
|
||||||
|
* registry namespaces community servers as `io.github.<user>/<repo>` (and
|
||||||
|
* `io.gitlab.<user>/…`), which maps 1:1 to a repo page. Returns `null` for
|
||||||
|
* vendor reverse-DNS slugs that don't encode a code host.
|
||||||
|
*/
|
||||||
|
export const deriveRepoUrl = (qualifiedName: string): string | null => {
|
||||||
|
const slash = qualifiedName.indexOf('/');
|
||||||
|
if (slash < 1) return null;
|
||||||
|
const prefix = qualifiedName.slice(0, slash);
|
||||||
|
const repo = qualifiedName.slice(slash + 1);
|
||||||
|
if (!repo) return null;
|
||||||
|
if (prefix.startsWith('io.github.')) {
|
||||||
|
return `https://github.com/${prefix.slice('io.github.'.length)}/${repo}`;
|
||||||
|
}
|
||||||
|
if (prefix.startsWith('io.gitlab.')) {
|
||||||
|
return `https://gitlab.com/${prefix.slice('io.gitlab.'.length)}/${repo}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Transport pill (Stdio vs Hosted) — the catalog's primary classification. */
|
||||||
|
const TransportBadge = ({ transport }: { transport: Transport }) => {
|
||||||
const { t } = useT();
|
const { t } = useT();
|
||||||
const hosted = !!deployed;
|
const hosted = transport === 'hosted';
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
title={t(hosted ? 'mcp.tab.transport.hostedHint' : 'mcp.tab.transport.localHint')}
|
title={t(hosted ? 'mcp.tab.transport.hostedHint' : 'mcp.tab.transport.localHint')}
|
||||||
className={`inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
|
className={`inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
|
||||||
hosted
|
hosted
|
||||||
? 'bg-primary-100 text-primary-700 dark:bg-primary-500/15 dark:text-primary-300'
|
? 'bg-primary-100 text-primary-700 dark:bg-primary-500/15 dark:text-primary-300'
|
||||||
: 'bg-stone-100 text-stone-500 dark:bg-neutral-800 dark:text-neutral-400'
|
: 'bg-surface-strong text-content-muted'
|
||||||
}`}>
|
}`}>
|
||||||
{t(hosted ? 'mcp.tab.transport.hosted' : 'mcp.tab.transport.local')}
|
{t(hosted ? 'mcp.tab.transport.hosted' : 'mcp.tab.transport.local')}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* External link that opens in the system browser. Stops propagation so clicking
|
||||||
|
* a server's website/repo never also triggers the row's install action.
|
||||||
|
*/
|
||||||
|
const ExternalLink = ({ href, label }: { href: string; label: string }) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void openUrl(href).catch(() => {});
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-0.5 text-[11px] text-primary-600 dark:text-primary-400 hover:underline">
|
||||||
|
{label}
|
||||||
|
<svg
|
||||||
|
className="w-2.5 h-2.5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
aria-hidden="true">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One catalog (registry) row. Memoized so the 5s status poll — which re-renders
|
||||||
|
* the parent tab — doesn't re-render the (potentially large) catalog list: the
|
||||||
|
* catalog data and the install handler are stable across polls, so memo skips
|
||||||
|
* every row. This is the main lever against the "rendering is slow" report.
|
||||||
|
*/
|
||||||
|
const CatalogRow = memo(
|
||||||
|
({
|
||||||
|
server,
|
||||||
|
onInstall,
|
||||||
|
}: {
|
||||||
|
server: SmitheryServer;
|
||||||
|
onInstall: (qualifiedName: string) => void;
|
||||||
|
}) => {
|
||||||
|
const { t } = useT();
|
||||||
|
const repoUrl = deriveRepoUrl(server.qualified_name);
|
||||||
|
const author = deriveAuthor(server.qualified_name);
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
className="hover:bg-surface-muted dark:hover:bg-surface-muted/40 cursor-pointer transition-colors"
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
aria-label={t('mcp.tab.aria.installServer').replace('{name}', server.display_name)}
|
||||||
|
onClick={() => onInstall(server.qualified_name)}
|
||||||
|
onKeyDown={e => {
|
||||||
|
// Only act on keys aimed at the row itself — Enter/Space bubble up
|
||||||
|
// from the nested Website/Repository buttons, which must not open
|
||||||
|
// the install flow.
|
||||||
|
if (e.target !== e.currentTarget) return;
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onInstall(server.qualified_name);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
{server.icon_url ? (
|
||||||
|
<img
|
||||||
|
src={server.icon_url}
|
||||||
|
alt=""
|
||||||
|
className="w-5 h-5 rounded shrink-0 object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="w-5 h-5 rounded shrink-0 bg-primary-100 dark:bg-primary-500/20 flex items-center justify-center text-[10px]">
|
||||||
|
🔌
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="font-medium text-content truncate">{server.display_name}</span>
|
||||||
|
{server.official && (
|
||||||
|
<span
|
||||||
|
title={t('mcp.tab.officialHint')}
|
||||||
|
className="inline-flex items-center gap-0.5 rounded-full bg-sage-100 px-1.5 py-0.5 text-[10px] font-medium text-sage-700 dark:bg-sage-500/15 dark:text-sage-300">
|
||||||
|
✓ {t('mcp.tab.officialBadge')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{/* The registry is full of look-alike names (a dozen "gmail"
|
||||||
|
servers); the slug is the unique identifier that tells them
|
||||||
|
apart. */}
|
||||||
|
<span className="text-[11px] font-mono text-content-faint truncate block">
|
||||||
|
{server.qualified_name}
|
||||||
|
</span>
|
||||||
|
{server.description && (
|
||||||
|
<span className="text-xs text-content-faint line-clamp-3 block">
|
||||||
|
{server.description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{(server.website_url || repoUrl) && (
|
||||||
|
<span className="flex items-center gap-3 mt-1">
|
||||||
|
{server.website_url && (
|
||||||
|
<ExternalLink href={server.website_url} label={t('mcp.tab.link.website')} />
|
||||||
|
)}
|
||||||
|
{repoUrl && <ExternalLink href={repoUrl} label={t('mcp.tab.link.repo')} />}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 hidden sm:table-cell">
|
||||||
|
<TransportBadge transport={transportOf(server)} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 hidden sm:table-cell">
|
||||||
|
<span className="text-xs text-content-muted truncate block">{author ?? '—'}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<span className="text-xs text-primary-600 dark:text-primary-400 font-medium">
|
||||||
|
{t('mcp.install.button')}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
CatalogRow.displayName = 'CatalogRow';
|
||||||
|
|
||||||
const McpServersTab = () => {
|
const McpServersTab = () => {
|
||||||
const { t } = useT();
|
const { t } = useT();
|
||||||
const [servers, setServers] = useState<InstalledServer[]>([]);
|
const [servers, setServers] = useState<InstalledServer[]>([]);
|
||||||
@@ -135,12 +262,17 @@ const McpServersTab = () => {
|
|||||||
// Unified search + filter
|
// Unified search + filter
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [activeChip, setActiveChip] = useState<FilterChip>('all');
|
const [activeChip, setActiveChip] = useState<FilterChip>('all');
|
||||||
|
// Secondary classification filter over catalog rows: by transport.
|
||||||
|
const [transportFilter, setTransportFilter] = useState<'all' | Transport>('all');
|
||||||
|
|
||||||
// Registry catalog results
|
// Registry catalog results
|
||||||
const [catalogServers, setCatalogServers] = useState<SmitheryServer[]>([]);
|
const [catalogServers, setCatalogServers] = useState<SmitheryServer[]>([]);
|
||||||
const [catalogLoading, setCatalogLoading] = useState(false);
|
const [catalogLoading, setCatalogLoading] = useState(false);
|
||||||
const [catalogPage, setCatalogPage] = useState(1);
|
const [catalogPage, setCatalogPage] = useState(1);
|
||||||
const [catalogTotalPages, setCatalogTotalPages] = useState(1);
|
const [catalogTotalPages, setCatalogTotalPages] = useState(1);
|
||||||
|
// Set when a registry fetch fails so the Registry view shows an error state
|
||||||
|
// (with retry) instead of silently falling back to an empty/stale catalog.
|
||||||
|
const [catalogError, setCatalogError] = useState(false);
|
||||||
|
|
||||||
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
@@ -167,42 +299,54 @@ const McpServersTab = () => {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchCatalog = useCallback(async (query: string, page: number, append: boolean) => {
|
const fetchCatalog = useCallback(
|
||||||
const seq = ++requestSeqRef.current;
|
async (query: string, transport: 'all' | Transport, page: number, append: boolean) => {
|
||||||
setCatalogLoading(true);
|
const seq = ++requestSeqRef.current;
|
||||||
try {
|
setCatalogLoading(true);
|
||||||
const result = await mcpClientsApi.registrySearch({
|
try {
|
||||||
query: query || undefined,
|
const result = await mcpClientsApi.registrySearch({
|
||||||
page,
|
query: query || undefined,
|
||||||
page_size: PAGE_SIZE,
|
transport: transport === 'all' ? undefined : transport,
|
||||||
});
|
page,
|
||||||
if (seq !== requestSeqRef.current) return;
|
page_size: PAGE_SIZE,
|
||||||
const incoming = result.servers ?? [];
|
});
|
||||||
setCatalogServers(prev => dedupeByQualifiedName(append ? [...prev, ...incoming] : incoming));
|
if (seq !== requestSeqRef.current) return;
|
||||||
setCatalogPage(result.page);
|
const incoming = result.servers ?? [];
|
||||||
setCatalogTotalPages(result.total_pages);
|
setCatalogServers(prev =>
|
||||||
} catch (err) {
|
dedupeByQualifiedName(append ? [...prev, ...incoming] : incoming)
|
||||||
if (seq !== requestSeqRef.current) return;
|
);
|
||||||
log('catalog fetch error: %o', err);
|
setCatalogPage(result.page);
|
||||||
} finally {
|
setCatalogTotalPages(result.total_pages);
|
||||||
if (seq === requestSeqRef.current) setCatalogLoading(false);
|
setCatalogError(false);
|
||||||
}
|
} catch (err) {
|
||||||
}, []);
|
if (seq !== requestSeqRef.current) return;
|
||||||
|
log('catalog fetch error: %o', err);
|
||||||
|
// A fresh (non-append) fetch that fails leaves no usable rows — surface
|
||||||
|
// the error. A failed "load more" keeps the rows already shown.
|
||||||
|
if (!append) setCatalogError(true);
|
||||||
|
} finally {
|
||||||
|
if (seq === requestSeqRef.current) setCatalogLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([loadInstalled(), fetchStatuses()]).finally(() => setLoading(false));
|
Promise.all([loadInstalled(), fetchStatuses()]).finally(() => setLoading(false));
|
||||||
}, [loadInstalled, fetchStatuses]);
|
}, [loadInstalled, fetchStatuses]);
|
||||||
|
|
||||||
// Fetch catalog on mount and when search changes
|
// Fetch catalog (page 1) on mount and whenever the query or transport filter
|
||||||
|
// changes. Search + transport now run in the core over the cached full
|
||||||
|
// catalog, so changing either re-queries from the top.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
debounceRef.current = setTimeout(() => {
|
debounceRef.current = setTimeout(() => {
|
||||||
void fetchCatalog(searchQuery, 1, false);
|
void fetchCatalog(searchQuery, transportFilter, 1, false);
|
||||||
}, DEBOUNCE_MS);
|
}, DEBOUNCE_MS);
|
||||||
return () => {
|
return () => {
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
};
|
};
|
||||||
}, [searchQuery, fetchCatalog]);
|
}, [searchQuery, transportFilter, fetchCatalog]);
|
||||||
|
|
||||||
// Poll status
|
// Poll status
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -274,9 +418,37 @@ const McpServersTab = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleLoadMore = () => {
|
const handleLoadMore = () => {
|
||||||
void fetchCatalog(searchQuery, catalogPage + 1, true);
|
void fetchCatalog(searchQuery, transportFilter, catalogPage + 1, true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Bulk lifecycle actions for the health toolbar. One failure doesn't abort the
|
||||||
|
// batch (allSettled), and we always refresh status so the dots reflect reality
|
||||||
|
// — but if any call rejected we then throw so the toolbar can surface the
|
||||||
|
// failure (otherwise a partial/total failure would look like success).
|
||||||
|
const handleReconnectAll = useCallback(
|
||||||
|
async (serverIds: string[]) => {
|
||||||
|
log('reconnect all: %o', serverIds);
|
||||||
|
const results = await Promise.allSettled(serverIds.map(id => mcpClientsApi.connect(id)));
|
||||||
|
await fetchStatuses();
|
||||||
|
if (results.some(r => r.status === 'rejected')) {
|
||||||
|
throw new Error(t('mcp.health.opErrorGeneric'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[fetchStatuses, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDisconnectAll = useCallback(
|
||||||
|
async (serverIds: string[]) => {
|
||||||
|
log('disconnect all: %o', serverIds);
|
||||||
|
const results = await Promise.allSettled(serverIds.map(id => mcpClientsApi.disconnect(id)));
|
||||||
|
await fetchStatuses();
|
||||||
|
if (results.some(r => r.status === 'rejected')) {
|
||||||
|
throw new Error(t('mcp.health.opErrorGeneric'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[fetchStatuses, t]
|
||||||
|
);
|
||||||
|
|
||||||
const selectedServer =
|
const selectedServer =
|
||||||
view.mode === 'detail' ? (servers.find(s => s.server_id === view.serverId) ?? null) : null;
|
view.mode === 'detail' ? (servers.find(s => s.server_id === view.serverId) ?? null) : null;
|
||||||
const selectedConnStatus =
|
const selectedConnStatus =
|
||||||
@@ -286,95 +458,43 @@ const McpServersTab = () => {
|
|||||||
// legacy double-installs can still exist on disk; collapse them by
|
// legacy double-installs can still exist on disk; collapse them by
|
||||||
// qualified_name (servers arrive earliest-first) so the list stays "one per
|
// qualified_name (servers arrive earliest-first) so the list stays "one per
|
||||||
// thing". Raw `servers` is kept for server_id-keyed detail/status lookups.
|
// thing". Raw `servers` is kept for server_id-keyed detail/status lookups.
|
||||||
const installedView = dedupeInstalledByQualifiedName(servers);
|
// Memoized so the 5s status poll doesn't rebuild + refilter the list.
|
||||||
|
const filteredInstalled = useMemo(() => {
|
||||||
// Filter installed servers by search
|
const view = dedupeInstalledByQualifiedName(servers);
|
||||||
const filteredInstalled = installedView.filter(s => {
|
const q = searchQuery.trim().toLowerCase();
|
||||||
if (!searchQuery.trim()) return true;
|
if (!q) return view;
|
||||||
const q = searchQuery.toLowerCase();
|
return view.filter(
|
||||||
return (
|
s =>
|
||||||
s.display_name.toLowerCase().includes(q) ||
|
s.display_name.toLowerCase().includes(q) ||
|
||||||
s.qualified_name.toLowerCase().includes(q) ||
|
s.qualified_name.toLowerCase().includes(q) ||
|
||||||
(s.description ?? '').toLowerCase().includes(q)
|
(s.description ?? '').toLowerCase().includes(q)
|
||||||
);
|
);
|
||||||
});
|
}, [servers, searchQuery]);
|
||||||
|
|
||||||
|
// Catalog rows minus already-installed servers. Search + transport filtering
|
||||||
|
// now happen in the core over the cached full catalog (so relevance and
|
||||||
|
// pagination are accurate); the only thing left to do client-side is hide
|
||||||
|
// servers the user already installed. Memoized so the status poll doesn't
|
||||||
|
// recompute it.
|
||||||
|
const availableCatalog = useMemo(() => {
|
||||||
|
const installedNames = new Set(servers.map(s => s.qualified_name));
|
||||||
|
return catalogServers.filter(s => !installedNames.has(s.qualified_name));
|
||||||
|
}, [catalogServers, servers]);
|
||||||
|
|
||||||
// Catalog rows (minus already-installed), in the registry's relevance order.
|
|
||||||
// We deliberately do NOT split by auth method here: `is_deployed` only tells
|
|
||||||
// us a server has a hosted endpoint, not whether it signs in via the browser
|
|
||||||
// or wants a pasted token — that's only known once the install dialog fetches
|
|
||||||
// the detail (`required_env_keys`) or the connect attempt hits an OAuth
|
|
||||||
// challenge. Each row carries a Hosted/Local hint badge instead; the real
|
|
||||||
// auth requirement surfaces in the install flow.
|
|
||||||
const installedNames = new Set(servers.map(s => s.qualified_name));
|
|
||||||
const availableCatalog = catalogServers.filter(s => !installedNames.has(s.qualified_name));
|
|
||||||
const showRegistry = activeChip === 'all' || activeChip === 'registry';
|
const showRegistry = activeChip === 'all' || activeChip === 'registry';
|
||||||
|
|
||||||
const renderCatalogRow = (server: SmitheryServer) => (
|
// Render catalog rows from memoized data so they survive parent re-renders
|
||||||
<tr
|
// (the status poll) untouched — `CatalogRow` is itself memoized.
|
||||||
key={`catalog-${server.qualified_name}`}
|
const catalogRows = useMemo(
|
||||||
className="hover:bg-stone-50 dark:hover:bg-neutral-800/40 cursor-pointer transition-colors"
|
() =>
|
||||||
tabIndex={0}
|
availableCatalog.map(server => (
|
||||||
role="button"
|
<CatalogRow
|
||||||
aria-label={t('mcp.tab.aria.installServer').replace('{name}', server.display_name)}
|
key={`catalog-${server.qualified_name}`}
|
||||||
onClick={() => handleSelectInstall(server.qualified_name)}
|
server={server}
|
||||||
onKeyDown={e => {
|
onInstall={handleSelectInstall}
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
/>
|
||||||
e.preventDefault();
|
)),
|
||||||
handleSelectInstall(server.qualified_name);
|
[availableCatalog, handleSelectInstall]
|
||||||
}
|
|
||||||
}}>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
{server.icon_url ? (
|
|
||||||
<img src={server.icon_url} alt="" className="w-5 h-5 rounded shrink-0 object-contain" />
|
|
||||||
) : (
|
|
||||||
<span className="w-5 h-5 rounded shrink-0 bg-primary-100 dark:bg-primary-500/20 flex items-center justify-center text-[10px]">
|
|
||||||
🔌
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<div className="min-w-0">
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<span className="font-medium text-stone-900 dark:text-neutral-100 truncate">
|
|
||||||
{server.display_name}
|
|
||||||
</span>
|
|
||||||
{server.official && (
|
|
||||||
<span
|
|
||||||
title={t('mcp.tab.officialHint')}
|
|
||||||
className="inline-flex items-center gap-0.5 rounded-full bg-sage-100 px-1.5 py-0.5 text-[10px] font-medium text-sage-700 dark:bg-sage-500/15 dark:text-sage-300">
|
|
||||||
✓ {t('mcp.tab.officialBadge')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<TransportHintBadge deployed={server.is_deployed} />
|
|
||||||
</span>
|
|
||||||
{/* The registry is full of look-alike names (a dozen "gmail"
|
|
||||||
servers); the slug is the unique identifier that tells them
|
|
||||||
apart. */}
|
|
||||||
<span className="text-[11px] font-mono text-stone-400 dark:text-neutral-500 truncate block">
|
|
||||||
{server.qualified_name}
|
|
||||||
</span>
|
|
||||||
{server.description && (
|
|
||||||
<span className="text-xs text-stone-400 dark:text-neutral-500 line-clamp-3 block">
|
|
||||||
{server.description}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 hidden sm:table-cell">
|
|
||||||
<SourceBadge source={server.source} />
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 hidden sm:table-cell">
|
|
||||||
<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">
|
|
||||||
<span className="text-xs text-primary-600 dark:text-primary-400 font-medium">
|
|
||||||
{t('mcp.install.button')}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const statusMap = new Map(statuses.map(s => [s.server_id, s]));
|
const statusMap = new Map(statuses.map(s => [s.server_id, s]));
|
||||||
@@ -415,26 +535,11 @@ const McpServersTab = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Install view
|
// Install view — InstallDialog renders its own "← Go back", so the tab does
|
||||||
|
// NOT add a second one here (that was the duplicate back button).
|
||||||
if (view.mode === 'install') {
|
if (view.mode === 'install') {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Button
|
|
||||||
variant="tertiary"
|
|
||||||
size="xs"
|
|
||||||
onClick={() => setView({ mode: 'home' })}
|
|
||||||
leadingIcon={
|
|
||||||
<svg
|
|
||||||
className="w-3.5 h-3.5"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
|
||||||
</svg>
|
|
||||||
}>
|
|
||||||
{t('mcp.install.back')}
|
|
||||||
</Button>
|
|
||||||
<InstallDialog
|
<InstallDialog
|
||||||
qualifiedName={view.qualifiedName}
|
qualifiedName={view.qualifiedName}
|
||||||
prefillEnv={view.prefillEnv}
|
prefillEnv={view.prefillEnv}
|
||||||
@@ -468,23 +573,59 @@ const McpServersTab = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter chips */}
|
{/* Filters on one bar: the scope (All / Installed / Registry) chips, then —
|
||||||
<ChipTabs<FilterChip>
|
when registry rows are visible — a labelled transport filter rendered
|
||||||
className="flex flex-wrap items-center gap-2"
|
as Stdio/Hosted TOGGLES (no second "All" chip; deselecting both means
|
||||||
value={activeChip}
|
all). This avoids the duplicate-"All" confusion. */}
|
||||||
onChange={setActiveChip}
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
items={[
|
<ChipTabs<FilterChip>
|
||||||
{ id: 'all', label: t('mcp.tab.filter.all') },
|
className="flex flex-wrap items-center gap-2"
|
||||||
{
|
value={activeChip}
|
||||||
id: 'installed',
|
onChange={setActiveChip}
|
||||||
label: t('mcp.tab.filter.installed').replace(
|
items={[
|
||||||
'{count}',
|
{ id: 'all', label: t('mcp.tab.filter.all') },
|
||||||
String(filteredInstalled.length)
|
{
|
||||||
),
|
id: 'installed',
|
||||||
},
|
label: t('mcp.tab.filter.installed').replace(
|
||||||
{ id: 'registry', label: t('mcp.tab.filter.registry') },
|
'{count}',
|
||||||
]}
|
String(filteredInstalled.length)
|
||||||
/>
|
),
|
||||||
|
},
|
||||||
|
{ id: 'registry', label: t('mcp.tab.filter.registry') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{showRegistry && (
|
||||||
|
<>
|
||||||
|
<span className="hidden sm:block h-5 w-px bg-line-subtle" aria-hidden="true" />
|
||||||
|
<span className="text-xs font-medium text-content-muted">
|
||||||
|
{t('mcp.tab.transportFilter.label')}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
className="flex flex-wrap items-center gap-2"
|
||||||
|
role="group"
|
||||||
|
aria-label={t('mcp.tab.transportFilter.aria')}>
|
||||||
|
{(['stdio', 'hosted'] as const).map(tp => {
|
||||||
|
const active = transportFilter === tp;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tp}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={active}
|
||||||
|
onClick={() => setTransportFilter(prev => (prev === tp ? 'all' : tp))}
|
||||||
|
className={`rounded-full px-3 py-1 text-xs font-medium transition-colors ${
|
||||||
|
active
|
||||||
|
? 'bg-content text-surface'
|
||||||
|
: 'border border-line text-content-muted hover:bg-surface-muted'
|
||||||
|
}`}>
|
||||||
|
{t(tp === 'stdio' ? 'mcp.tab.transport.local' : 'mcp.tab.transport.hosted')}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{loadError && (
|
{loadError && (
|
||||||
<div className="rounded-lg border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-3 py-2 text-xs text-coral-700 dark:text-coral-300">
|
<div className="rounded-lg border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-3 py-2 text-xs text-coral-700 dark:text-coral-300">
|
||||||
@@ -492,6 +633,18 @@ const McpServersTab = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Connection health + bulk lifecycle actions. Only meaningful once
|
||||||
|
servers are installed; surfaces "Retry all" for error-state servers
|
||||||
|
(the failed-connection retry affordance #4272 asks for) and
|
||||||
|
"Disconnect all". Reads the polled statuses — no extra fetches. */}
|
||||||
|
{(activeChip === 'all' || activeChip === 'installed') && statuses.length > 0 && (
|
||||||
|
<McpConnectionHealthToolbar
|
||||||
|
statuses={statuses}
|
||||||
|
onReconnect={handleReconnectAll}
|
||||||
|
onDisconnect={handleDisconnectAll}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Table — horizontally scrollable so the Source/Author/Action columns
|
{/* Table — horizontally scrollable so the Source/Author/Action columns
|
||||||
aren't clipped when the panel is narrower than the table's natural
|
aren't clipped when the panel is narrower than the table's natural
|
||||||
width (the wrapper was `overflow-hidden`, which cut them off with no
|
width (the wrapper was `overflow-hidden`, which cut them off with no
|
||||||
@@ -505,7 +658,7 @@ const McpServersTab = () => {
|
|||||||
{t('mcp.tab.column.name')}
|
{t('mcp.tab.column.name')}
|
||||||
</th>
|
</th>
|
||||||
<th className="text-left px-4 py-2.5 text-xs font-medium text-content-muted hidden sm:table-cell w-28">
|
<th className="text-left px-4 py-2.5 text-xs font-medium text-content-muted hidden sm:table-cell w-28">
|
||||||
{t('mcp.tab.column.source')}
|
{t('mcp.tab.column.type')}
|
||||||
</th>
|
</th>
|
||||||
<th className="text-left px-4 py-2.5 text-xs font-medium text-content-muted hidden sm:table-cell w-36">
|
<th className="text-left px-4 py-2.5 text-xs font-medium text-content-muted hidden sm:table-cell w-36">
|
||||||
{t('mcp.tab.column.author')}
|
{t('mcp.tab.column.author')}
|
||||||
@@ -573,12 +726,30 @@ const McpServersTab = () => {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Registry servers — one relevance-ordered list. Each row shows a
|
{/* Registry servers — official first, then the registry's relevance
|
||||||
Hosted/Local hint; the real auth method appears on install. */}
|
order. Each row shows its transport, website/repo links, and the
|
||||||
{showRegistry && availableCatalog.map(renderCatalogRow)}
|
real auth surfaces on install. */}
|
||||||
|
{showRegistry && catalogRows}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
{/* Registry fetch error — takes precedence over the empty state so a
|
||||||
|
failed load reads as an error (with retry), not "no results". */}
|
||||||
|
{showRegistry && catalogError && !catalogLoading && (
|
||||||
|
<div
|
||||||
|
data-testid="mcp-catalog-error"
|
||||||
|
className="py-8 text-center text-sm text-coral-700 dark:text-coral-300 space-y-2">
|
||||||
|
<p>{t('mcp.catalog.loadFailed')}</p>
|
||||||
|
<Button
|
||||||
|
variant="tertiary"
|
||||||
|
size="xs"
|
||||||
|
onClick={() => void fetchCatalog(searchQuery, transportFilter, 1, false)}
|
||||||
|
className="text-primary-600 dark:text-primary-400 hover:underline">
|
||||||
|
{t('common.retry')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Empty states */}
|
{/* Empty states */}
|
||||||
{activeChip === 'installed' && filteredInstalled.length === 0 && (
|
{activeChip === 'installed' && filteredInstalled.length === 0 && (
|
||||||
<div
|
<div
|
||||||
@@ -587,19 +758,23 @@ const McpServersTab = () => {
|
|||||||
{t('mcp.installed.empty')}
|
{t('mcp.installed.empty')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{activeChip === 'registry' && availableCatalog.length === 0 && !catalogLoading && (
|
{activeChip === 'registry' &&
|
||||||
<div
|
availableCatalog.length === 0 &&
|
||||||
data-testid="mcp-catalog-empty"
|
!catalogLoading &&
|
||||||
className="py-8 text-center text-sm text-content-faint">
|
!catalogError && (
|
||||||
{searchQuery
|
<div
|
||||||
? t('mcp.catalog.noResultsFor').replace('{query}', searchQuery)
|
data-testid="mcp-catalog-empty"
|
||||||
: t('mcp.catalog.noResults')}
|
className="py-8 text-center text-sm text-content-faint">
|
||||||
</div>
|
{searchQuery
|
||||||
)}
|
? t('mcp.catalog.noResultsFor').replace('{query}', searchQuery)
|
||||||
|
: t('mcp.catalog.noResults')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{activeChip === 'all' &&
|
{activeChip === 'all' &&
|
||||||
filteredInstalled.length === 0 &&
|
filteredInstalled.length === 0 &&
|
||||||
availableCatalog.length === 0 &&
|
availableCatalog.length === 0 &&
|
||||||
!catalogLoading && (
|
!catalogLoading &&
|
||||||
|
!catalogError && (
|
||||||
<div
|
<div
|
||||||
data-testid="mcp-catalog-empty"
|
data-testid="mcp-catalog-empty"
|
||||||
className="py-8 text-center text-sm text-content-faint">
|
className="py-8 text-center text-sm text-content-faint">
|
||||||
|
|||||||
@@ -23,6 +23,18 @@ export type SmitheryServer = {
|
|||||||
* hidden. Stamped by the Rust dispatcher; never trusted from the wire.
|
* hidden. Stamped by the Rust dispatcher; never trusted from the wire.
|
||||||
*/
|
*/
|
||||||
official?: boolean;
|
official?: boolean;
|
||||||
|
/**
|
||||||
|
* Vendor/site URL declared by the server, when present. The strict catalog
|
||||||
|
* filter requires it, so every listed row carries one; rendered as a
|
||||||
|
* clickable external link.
|
||||||
|
*/
|
||||||
|
website_url?: string;
|
||||||
|
/**
|
||||||
|
* Declared auth method from registry metadata. `'api_key'` means the server
|
||||||
|
* declares a named static secret (header/env). The strict filter only lists
|
||||||
|
* `'api_key'` servers, so connecting never depends on a probe guess.
|
||||||
|
*/
|
||||||
|
auth_kind?: 'api_key' | string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SmitheryConnection = {
|
export type SmitheryConnection = {
|
||||||
|
|||||||
@@ -1356,6 +1356,7 @@ const messages: TranslationMap = {
|
|||||||
'mcp.catalog.loadMore': 'تحميل المزيد',
|
'mcp.catalog.loadMore': 'تحميل المزيد',
|
||||||
'mcp.configAssistant.title': 'مساعد التكوين',
|
'mcp.configAssistant.title': 'مساعد التكوين',
|
||||||
'mcp.configAssistant.empty': 'اسأل عن التكوين أو متغيرات env المطلوبة أو خطوات الإعداد.',
|
'mcp.configAssistant.empty': 'اسأل عن التكوين أو متغيرات env المطلوبة أو خطوات الإعداد.',
|
||||||
|
'mcp.configAssistant.autoPromptCta': 'احصل على مساعدة الإعداد خطوة بخطوة',
|
||||||
'mcp.configAssistant.suggestedValues': 'القيم المقترحة:',
|
'mcp.configAssistant.suggestedValues': 'القيم المقترحة:',
|
||||||
'mcp.configAssistant.valueHidden': '(القيمة مخفية)',
|
'mcp.configAssistant.valueHidden': '(القيمة مخفية)',
|
||||||
'mcp.configAssistant.applySuggested': 'تطبيق القيم المقترحة',
|
'mcp.configAssistant.applySuggested': 'تطبيق القيم المقترحة',
|
||||||
@@ -1495,13 +1496,15 @@ const messages: TranslationMap = {
|
|||||||
'mcp.tab.filter.registry': 'السجل',
|
'mcp.tab.filter.registry': 'السجل',
|
||||||
'mcp.tab.column.name': 'الاسم',
|
'mcp.tab.column.name': 'الاسم',
|
||||||
'mcp.tab.column.description': 'الوصف',
|
'mcp.tab.column.description': 'الوصف',
|
||||||
'mcp.tab.column.source': 'المصدر',
|
'mcp.tab.column.type': 'النوع',
|
||||||
'mcp.tab.column.author': 'المؤلف',
|
'mcp.tab.column.author': 'المؤلف',
|
||||||
'mcp.tab.column.action': 'الإجراء',
|
'mcp.tab.column.action': 'الإجراء',
|
||||||
'mcp.tab.source.official': 'رسمي',
|
|
||||||
'mcp.tab.source.smithery': 'Smithery',
|
|
||||||
'mcp.tab.transport.hosted': 'مستضاف',
|
'mcp.tab.transport.hosted': 'مستضاف',
|
||||||
'mcp.tab.transport.local': 'محلي',
|
'mcp.tab.transport.local': 'Stdio',
|
||||||
|
'mcp.tab.transportFilter.label': 'النوع',
|
||||||
|
'mcp.tab.transportFilter.aria': 'تصفية الخوادم حسب وسيلة النقل',
|
||||||
|
'mcp.tab.link.website': 'الموقع',
|
||||||
|
'mcp.tab.link.repo': 'المستودع',
|
||||||
'mcp.tab.transport.hostedHint': 'يعمل على خادم بعيد — يُعدّ تسجيل الدخول أو الرمز عند التثبيت',
|
'mcp.tab.transport.hostedHint': 'يعمل على خادم بعيد — يُعدّ تسجيل الدخول أو الرمز عند التثبيت',
|
||||||
'mcp.tab.transport.localHint': 'يعمل على جهازك — قد يحتاج إلى رمز عند التثبيت',
|
'mcp.tab.transport.localHint': 'يعمل على جهازك — قد يحتاج إلى رمز عند التثبيت',
|
||||||
'mcp.tab.officialBadge': 'رسمي',
|
'mcp.tab.officialBadge': 'رسمي',
|
||||||
@@ -1527,11 +1530,7 @@ const messages: TranslationMap = {
|
|||||||
'mcp.install.button': 'تثبيت',
|
'mcp.install.button': 'تثبيت',
|
||||||
'mcp.install.installing': 'جارٍ التثبيت...',
|
'mcp.install.installing': 'جارٍ التثبيت...',
|
||||||
'mcp.install.by': 'بواسطة',
|
'mcp.install.by': 'بواسطة',
|
||||||
'mcp.install.transportLocal': 'يعمل محليًا',
|
|
||||||
'mcp.install.transportRemote': 'مستضاف سحابيًا',
|
|
||||||
'mcp.install.useCount': 'عمليات تثبيت {count}',
|
'mcp.install.useCount': 'عمليات تثبيت {count}',
|
||||||
'mcp.install.deployed': 'منشور',
|
|
||||||
'mcp.install.requiresConfig': 'يتطلب تكوينًا',
|
|
||||||
'mcp.install.connections': 'الاتصالات المتاحة',
|
'mcp.install.connections': 'الاتصالات المتاحة',
|
||||||
'mcp.install.published': 'منشور',
|
'mcp.install.published': 'منشور',
|
||||||
'mcp.install.configureAndInstall': 'تكوين وتثبيت',
|
'mcp.install.configureAndInstall': 'تكوين وتثبيت',
|
||||||
|
|||||||
@@ -1378,6 +1378,7 @@ const messages: TranslationMap = {
|
|||||||
'mcp.catalog.loadMore': 'আরও লোড করুন',
|
'mcp.catalog.loadMore': 'আরও লোড করুন',
|
||||||
'mcp.configAssistant.title': 'কনফিগারেশন সহকারী',
|
'mcp.configAssistant.title': 'কনফিগারেশন সহকারী',
|
||||||
'mcp.configAssistant.empty': 'কনফিগারেশন জানার জন্য, বিবিধ বৈশিষ্ট্য অথবা প্রস্তুতির প্রয়োজন।',
|
'mcp.configAssistant.empty': 'কনফিগারেশন জানার জন্য, বিবিধ বৈশিষ্ট্য অথবা প্রস্তুতির প্রয়োজন।',
|
||||||
|
'mcp.configAssistant.autoPromptCta': 'ধাপে ধাপে সেটআপ সহায়তা নিন',
|
||||||
'mcp.configAssistant.suggestedValues': 'প্রস্তাবিত মান:',
|
'mcp.configAssistant.suggestedValues': 'প্রস্তাবিত মান:',
|
||||||
'mcp.configAssistant.valueHidden': '(মান লুকানো)',
|
'mcp.configAssistant.valueHidden': '(মান লুকানো)',
|
||||||
'mcp.configAssistant.applySuggested': 'প্রস্তাবিত মান প্রয়োগ করুন',
|
'mcp.configAssistant.applySuggested': 'প্রস্তাবিত মান প্রয়োগ করুন',
|
||||||
@@ -1523,13 +1524,15 @@ const messages: TranslationMap = {
|
|||||||
'mcp.tab.filter.registry': 'রেজিস্ট্রি',
|
'mcp.tab.filter.registry': 'রেজিস্ট্রি',
|
||||||
'mcp.tab.column.name': 'নাম',
|
'mcp.tab.column.name': 'নাম',
|
||||||
'mcp.tab.column.description': 'বিবরণ',
|
'mcp.tab.column.description': 'বিবরণ',
|
||||||
'mcp.tab.column.source': 'উৎস',
|
'mcp.tab.column.type': 'ধরন',
|
||||||
'mcp.tab.column.author': 'লেখক',
|
'mcp.tab.column.author': 'লেখক',
|
||||||
'mcp.tab.column.action': 'ক্রিয়া',
|
'mcp.tab.column.action': 'ক্রিয়া',
|
||||||
'mcp.tab.source.official': 'অফিসিয়াল',
|
|
||||||
'mcp.tab.source.smithery': 'Smithery',
|
|
||||||
'mcp.tab.transport.hosted': 'হোস্টেড',
|
'mcp.tab.transport.hosted': 'হোস্টেড',
|
||||||
'mcp.tab.transport.local': 'লোকাল',
|
'mcp.tab.transport.local': 'Stdio',
|
||||||
|
'mcp.tab.transportFilter.label': 'ধরন',
|
||||||
|
'mcp.tab.transportFilter.aria': 'ট্রান্সপোর্ট অনুযায়ী সার্ভার ফিল্টার করুন',
|
||||||
|
'mcp.tab.link.website': 'ওয়েবসাইট',
|
||||||
|
'mcp.tab.link.repo': 'রিপোজিটরি',
|
||||||
'mcp.tab.transport.hostedHint':
|
'mcp.tab.transport.hostedHint':
|
||||||
'একটি দূরবর্তী সার্ভারে চলে — ইনস্টলের সময় সাইন-ইন বা টোকেন সেট করা হয়',
|
'একটি দূরবর্তী সার্ভারে চলে — ইনস্টলের সময় সাইন-ইন বা টোকেন সেট করা হয়',
|
||||||
'mcp.tab.transport.localHint': 'আপনার ডিভাইসে চলে — ইনস্টলের সময় টোকেন লাগতে পারে',
|
'mcp.tab.transport.localHint': 'আপনার ডিভাইসে চলে — ইনস্টলের সময় টোকেন লাগতে পারে',
|
||||||
@@ -1556,11 +1559,7 @@ const messages: TranslationMap = {
|
|||||||
'mcp.install.button': 'ইনস্টল করুন',
|
'mcp.install.button': 'ইনস্টল করুন',
|
||||||
'mcp.install.installing': 'ইনস্টল করা হচ্ছে...',
|
'mcp.install.installing': 'ইনস্টল করা হচ্ছে...',
|
||||||
'mcp.install.by': 'দ্বারা',
|
'mcp.install.by': 'দ্বারা',
|
||||||
'mcp.install.transportLocal': 'স্থানীয়ভাবে চলে',
|
|
||||||
'mcp.install.transportRemote': 'ক্লাউডে হোস্ট করা',
|
|
||||||
'mcp.install.useCount': '{count} ইনস্টলেশন',
|
'mcp.install.useCount': '{count} ইনস্টলেশন',
|
||||||
'mcp.install.deployed': 'স্থাপিত',
|
|
||||||
'mcp.install.requiresConfig': 'কনফিগারেশন প্রয়োজন',
|
|
||||||
'mcp.install.connections': 'উপলব্ধ সংযোগ',
|
'mcp.install.connections': 'উপলব্ধ সংযোগ',
|
||||||
'mcp.install.published': 'প্রকাশিত',
|
'mcp.install.published': 'প্রকাশিত',
|
||||||
'mcp.install.configureAndInstall': 'কনফিগার করুন ও ইনস্টল করুন',
|
'mcp.install.configureAndInstall': 'কনফিগার করুন ও ইনস্টল করুন',
|
||||||
|
|||||||
@@ -1416,6 +1416,7 @@ const messages: TranslationMap = {
|
|||||||
'mcp.configAssistant.title': 'Konfigurationsassistent',
|
'mcp.configAssistant.title': 'Konfigurationsassistent',
|
||||||
'mcp.configAssistant.empty':
|
'mcp.configAssistant.empty':
|
||||||
'Fragen Sie nach Konfiguration, erforderlichen Umgebungsvariablen oder Einrichtungsschritten.',
|
'Fragen Sie nach Konfiguration, erforderlichen Umgebungsvariablen oder Einrichtungsschritten.',
|
||||||
|
'mcp.configAssistant.autoPromptCta': 'Schritt-für-Schritt-Einrichtungshilfe',
|
||||||
'mcp.configAssistant.suggestedValues': 'Vorgeschlagene Werte:',
|
'mcp.configAssistant.suggestedValues': 'Vorgeschlagene Werte:',
|
||||||
'mcp.configAssistant.valueHidden': '(Wert ausgeblendet)',
|
'mcp.configAssistant.valueHidden': '(Wert ausgeblendet)',
|
||||||
'mcp.configAssistant.applySuggested': 'Vorgeschlagene Werte anwenden',
|
'mcp.configAssistant.applySuggested': 'Vorgeschlagene Werte anwenden',
|
||||||
@@ -1565,13 +1566,15 @@ const messages: TranslationMap = {
|
|||||||
'mcp.tab.filter.registry': 'Registrierung',
|
'mcp.tab.filter.registry': 'Registrierung',
|
||||||
'mcp.tab.column.name': 'Name',
|
'mcp.tab.column.name': 'Name',
|
||||||
'mcp.tab.column.description': 'Beschreibung',
|
'mcp.tab.column.description': 'Beschreibung',
|
||||||
'mcp.tab.column.source': 'Quelle',
|
'mcp.tab.column.type': 'Typ',
|
||||||
'mcp.tab.column.author': 'Autor',
|
'mcp.tab.column.author': 'Autor',
|
||||||
'mcp.tab.column.action': 'Aktion',
|
'mcp.tab.column.action': 'Aktion',
|
||||||
'mcp.tab.source.official': 'Offiziell',
|
|
||||||
'mcp.tab.source.smithery': 'Smithery',
|
|
||||||
'mcp.tab.transport.hosted': 'Gehostet',
|
'mcp.tab.transport.hosted': 'Gehostet',
|
||||||
'mcp.tab.transport.local': 'Lokal',
|
'mcp.tab.transport.local': 'Stdio',
|
||||||
|
'mcp.tab.transportFilter.label': 'Typ',
|
||||||
|
'mcp.tab.transportFilter.aria': 'Server nach Transport filtern',
|
||||||
|
'mcp.tab.link.website': 'Website',
|
||||||
|
'mcp.tab.link.repo': 'Repository',
|
||||||
'mcp.tab.transport.hostedHint':
|
'mcp.tab.transport.hostedHint':
|
||||||
'Läuft auf einem entfernten Server – Anmeldung oder Token wird bei der Installation eingerichtet',
|
'Läuft auf einem entfernten Server – Anmeldung oder Token wird bei der Installation eingerichtet',
|
||||||
'mcp.tab.transport.localHint':
|
'mcp.tab.transport.localHint':
|
||||||
@@ -1599,11 +1602,7 @@ const messages: TranslationMap = {
|
|||||||
'mcp.install.button': 'Installation',
|
'mcp.install.button': 'Installation',
|
||||||
'mcp.install.installing': 'Installation...',
|
'mcp.install.installing': 'Installation...',
|
||||||
'mcp.install.by': 'von',
|
'mcp.install.by': 'von',
|
||||||
'mcp.install.transportLocal': 'Lokal ausgeführt',
|
|
||||||
'mcp.install.transportRemote': 'Cloud-gehostet',
|
|
||||||
'mcp.install.useCount': '{count} Installationen',
|
'mcp.install.useCount': '{count} Installationen',
|
||||||
'mcp.install.deployed': 'Bereitgestellt',
|
|
||||||
'mcp.install.requiresConfig': 'Konfiguration erforderlich',
|
|
||||||
'mcp.install.connections': 'Verfügbare Verbindungen',
|
'mcp.install.connections': 'Verfügbare Verbindungen',
|
||||||
'mcp.install.published': 'veröffentlicht',
|
'mcp.install.published': 'veröffentlicht',
|
||||||
'mcp.install.configureAndInstall': 'Konfigurieren & installieren',
|
'mcp.install.configureAndInstall': 'Konfigurieren & installieren',
|
||||||
|
|||||||
@@ -1729,6 +1729,7 @@ const en: TranslationMap = {
|
|||||||
'mcp.catalog.loadMore': 'Load more',
|
'mcp.catalog.loadMore': 'Load more',
|
||||||
'mcp.configAssistant.title': 'Configuration assistant',
|
'mcp.configAssistant.title': 'Configuration assistant',
|
||||||
'mcp.configAssistant.empty': 'Ask about configuration, required env vars, or setup steps.',
|
'mcp.configAssistant.empty': 'Ask about configuration, required env vars, or setup steps.',
|
||||||
|
'mcp.configAssistant.autoPromptCta': 'Get step-by-step setup help',
|
||||||
'mcp.configAssistant.suggestedValues': 'Suggested values:',
|
'mcp.configAssistant.suggestedValues': 'Suggested values:',
|
||||||
'mcp.configAssistant.valueHidden': '(value hidden)',
|
'mcp.configAssistant.valueHidden': '(value hidden)',
|
||||||
'mcp.configAssistant.applySuggested': 'Apply suggested values',
|
'mcp.configAssistant.applySuggested': 'Apply suggested values',
|
||||||
@@ -1872,16 +1873,18 @@ const en: TranslationMap = {
|
|||||||
'mcp.tab.filter.registry': 'Registry',
|
'mcp.tab.filter.registry': 'Registry',
|
||||||
'mcp.tab.column.name': 'Name',
|
'mcp.tab.column.name': 'Name',
|
||||||
'mcp.tab.column.description': 'Description',
|
'mcp.tab.column.description': 'Description',
|
||||||
'mcp.tab.column.source': 'Source',
|
'mcp.tab.column.type': 'Type',
|
||||||
'mcp.tab.column.author': 'Author',
|
'mcp.tab.column.author': 'Author',
|
||||||
'mcp.tab.column.action': 'Action',
|
'mcp.tab.column.action': 'Action',
|
||||||
'mcp.tab.source.official': 'Official',
|
|
||||||
'mcp.tab.source.smithery': 'Smithery',
|
|
||||||
'mcp.tab.transport.hosted': 'Hosted',
|
'mcp.tab.transport.hosted': 'Hosted',
|
||||||
'mcp.tab.transport.local': 'Local',
|
'mcp.tab.transport.local': 'Stdio',
|
||||||
'mcp.tab.transport.hostedHint':
|
'mcp.tab.transport.hostedHint':
|
||||||
'Runs on a remote server — sign-in or token is set up when you install',
|
'Runs on a remote server — sign-in or token is set up when you install',
|
||||||
'mcp.tab.transport.localHint': 'Runs on your device — may need a token when you install',
|
'mcp.tab.transport.localHint': 'Runs on your device — may need a token when you install',
|
||||||
|
'mcp.tab.transportFilter.label': 'Type',
|
||||||
|
'mcp.tab.transportFilter.aria': 'Filter servers by transport',
|
||||||
|
'mcp.tab.link.website': 'Website',
|
||||||
|
'mcp.tab.link.repo': 'Repository',
|
||||||
'mcp.tab.officialBadge': 'Official',
|
'mcp.tab.officialBadge': 'Official',
|
||||||
'mcp.tab.officialHint': 'Official server from the vendor',
|
'mcp.tab.officialHint': 'Official server from the vendor',
|
||||||
'mcp.tab.badge.installed': 'Installed',
|
'mcp.tab.badge.installed': 'Installed',
|
||||||
@@ -1905,11 +1908,7 @@ const en: TranslationMap = {
|
|||||||
'mcp.install.button': 'Install',
|
'mcp.install.button': 'Install',
|
||||||
'mcp.install.installing': 'Installing...',
|
'mcp.install.installing': 'Installing...',
|
||||||
'mcp.install.by': 'by',
|
'mcp.install.by': 'by',
|
||||||
'mcp.install.transportLocal': 'Runs locally',
|
|
||||||
'mcp.install.transportRemote': 'Cloud hosted',
|
|
||||||
'mcp.install.useCount': '{count} installs',
|
'mcp.install.useCount': '{count} installs',
|
||||||
'mcp.install.deployed': 'Deployed',
|
|
||||||
'mcp.install.requiresConfig': 'Requires configuration',
|
|
||||||
'mcp.install.connections': 'Available connections',
|
'mcp.install.connections': 'Available connections',
|
||||||
'mcp.install.published': 'published',
|
'mcp.install.published': 'published',
|
||||||
'mcp.install.configureAndInstall': 'Configure & install',
|
'mcp.install.configureAndInstall': 'Configure & install',
|
||||||
|
|||||||
@@ -1407,6 +1407,7 @@ const messages: TranslationMap = {
|
|||||||
'mcp.configAssistant.title': 'Asistente de configuración',
|
'mcp.configAssistant.title': 'Asistente de configuración',
|
||||||
'mcp.configAssistant.empty':
|
'mcp.configAssistant.empty':
|
||||||
'Pregunte sobre la configuración, las variables de entorno requeridas o los pasos de configuración.',
|
'Pregunte sobre la configuración, las variables de entorno requeridas o los pasos de configuración.',
|
||||||
|
'mcp.configAssistant.autoPromptCta': 'Obtener ayuda de configuración paso a paso',
|
||||||
'mcp.configAssistant.suggestedValues': 'Valores sugeridos:',
|
'mcp.configAssistant.suggestedValues': 'Valores sugeridos:',
|
||||||
'mcp.configAssistant.valueHidden': '(valor oculto)',
|
'mcp.configAssistant.valueHidden': '(valor oculto)',
|
||||||
'mcp.configAssistant.applySuggested': 'Aplicar valores sugeridos',
|
'mcp.configAssistant.applySuggested': 'Aplicar valores sugeridos',
|
||||||
@@ -1557,13 +1558,15 @@ const messages: TranslationMap = {
|
|||||||
'mcp.tab.filter.registry': 'Registro',
|
'mcp.tab.filter.registry': 'Registro',
|
||||||
'mcp.tab.column.name': 'Nombre',
|
'mcp.tab.column.name': 'Nombre',
|
||||||
'mcp.tab.column.description': 'Descripción',
|
'mcp.tab.column.description': 'Descripción',
|
||||||
'mcp.tab.column.source': 'Origen',
|
'mcp.tab.column.type': 'Tipo',
|
||||||
'mcp.tab.column.author': 'Autor',
|
'mcp.tab.column.author': 'Autor',
|
||||||
'mcp.tab.column.action': 'Acción',
|
'mcp.tab.column.action': 'Acción',
|
||||||
'mcp.tab.source.official': 'Oficial',
|
|
||||||
'mcp.tab.source.smithery': 'Smithery',
|
|
||||||
'mcp.tab.transport.hosted': 'Alojado',
|
'mcp.tab.transport.hosted': 'Alojado',
|
||||||
'mcp.tab.transport.local': 'Local',
|
'mcp.tab.transport.local': 'Stdio',
|
||||||
|
'mcp.tab.transportFilter.label': 'Tipo',
|
||||||
|
'mcp.tab.transportFilter.aria': 'Filtrar servidores por transporte',
|
||||||
|
'mcp.tab.link.website': 'Sitio web',
|
||||||
|
'mcp.tab.link.repo': 'Repositorio',
|
||||||
'mcp.tab.transport.hostedHint':
|
'mcp.tab.transport.hostedHint':
|
||||||
'Se ejecuta en un servidor remoto: el inicio de sesión o el token se configura al instalar',
|
'Se ejecuta en un servidor remoto: el inicio de sesión o el token se configura al instalar',
|
||||||
'mcp.tab.transport.localHint':
|
'mcp.tab.transport.localHint':
|
||||||
@@ -1591,11 +1594,7 @@ const messages: TranslationMap = {
|
|||||||
'mcp.install.button': 'Instalar',
|
'mcp.install.button': 'Instalar',
|
||||||
'mcp.install.installing': 'Instalando...',
|
'mcp.install.installing': 'Instalando...',
|
||||||
'mcp.install.by': 'por',
|
'mcp.install.by': 'por',
|
||||||
'mcp.install.transportLocal': 'Se ejecuta localmente',
|
|
||||||
'mcp.install.transportRemote': 'Alojado en la nube',
|
|
||||||
'mcp.install.useCount': '{count} instalaciones',
|
'mcp.install.useCount': '{count} instalaciones',
|
||||||
'mcp.install.deployed': 'Desplegado',
|
|
||||||
'mcp.install.requiresConfig': 'Requiere configuración',
|
|
||||||
'mcp.install.connections': 'Conexiones disponibles',
|
'mcp.install.connections': 'Conexiones disponibles',
|
||||||
'mcp.install.published': 'publicado',
|
'mcp.install.published': 'publicado',
|
||||||
'mcp.install.configureAndInstall': 'Configurar e instalar',
|
'mcp.install.configureAndInstall': 'Configurar e instalar',
|
||||||
|
|||||||
@@ -1417,6 +1417,7 @@ const messages: TranslationMap = {
|
|||||||
'mcp.configAssistant.title': 'Assistant de configuration',
|
'mcp.configAssistant.title': 'Assistant de configuration',
|
||||||
'mcp.configAssistant.empty':
|
'mcp.configAssistant.empty':
|
||||||
"Demandez à propos de la configuration, des variables d'environnement requises ou des étapes de configuration.",
|
"Demandez à propos de la configuration, des variables d'environnement requises ou des étapes de configuration.",
|
||||||
|
'mcp.configAssistant.autoPromptCta': 'Obtenir une aide à la configuration étape par étape',
|
||||||
'mcp.configAssistant.suggestedValues': 'Valeurs suggérées :',
|
'mcp.configAssistant.suggestedValues': 'Valeurs suggérées :',
|
||||||
'mcp.configAssistant.valueHidden': '(valeur masquée)',
|
'mcp.configAssistant.valueHidden': '(valeur masquée)',
|
||||||
'mcp.configAssistant.applySuggested': 'Appliquer les valeurs suggérées',
|
'mcp.configAssistant.applySuggested': 'Appliquer les valeurs suggérées',
|
||||||
@@ -1566,13 +1567,15 @@ const messages: TranslationMap = {
|
|||||||
'mcp.tab.filter.registry': 'Registre',
|
'mcp.tab.filter.registry': 'Registre',
|
||||||
'mcp.tab.column.name': 'Nom',
|
'mcp.tab.column.name': 'Nom',
|
||||||
'mcp.tab.column.description': 'Description',
|
'mcp.tab.column.description': 'Description',
|
||||||
'mcp.tab.column.source': 'Source',
|
'mcp.tab.column.type': 'Type',
|
||||||
'mcp.tab.column.author': 'Auteur',
|
'mcp.tab.column.author': 'Auteur',
|
||||||
'mcp.tab.column.action': 'Action',
|
'mcp.tab.column.action': 'Action',
|
||||||
'mcp.tab.source.official': 'Officiel',
|
|
||||||
'mcp.tab.source.smithery': 'Smithery',
|
|
||||||
'mcp.tab.transport.hosted': 'Hébergé',
|
'mcp.tab.transport.hosted': 'Hébergé',
|
||||||
'mcp.tab.transport.local': 'Local',
|
'mcp.tab.transport.local': 'Stdio',
|
||||||
|
'mcp.tab.transportFilter.label': 'Type',
|
||||||
|
'mcp.tab.transportFilter.aria': 'Filtrer les serveurs par transport',
|
||||||
|
'mcp.tab.link.website': 'Site web',
|
||||||
|
'mcp.tab.link.repo': 'Dépôt',
|
||||||
'mcp.tab.transport.hostedHint':
|
'mcp.tab.transport.hostedHint':
|
||||||
"S'exécute sur un serveur distant — la connexion ou le jeton est configuré lors de l'installation",
|
"S'exécute sur un serveur distant — la connexion ou le jeton est configuré lors de l'installation",
|
||||||
'mcp.tab.transport.localHint':
|
'mcp.tab.transport.localHint':
|
||||||
@@ -1600,11 +1603,7 @@ const messages: TranslationMap = {
|
|||||||
'mcp.install.button': 'Installation',
|
'mcp.install.button': 'Installation',
|
||||||
'mcp.install.installing': 'Installation...',
|
'mcp.install.installing': 'Installation...',
|
||||||
'mcp.install.by': 'par',
|
'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.useCount': '{count} installations',
|
||||||
'mcp.install.deployed': 'Déployé',
|
|
||||||
'mcp.install.requiresConfig': 'Configuration requise',
|
|
||||||
'mcp.install.connections': 'Connexions disponibles',
|
'mcp.install.connections': 'Connexions disponibles',
|
||||||
'mcp.install.published': 'publié',
|
'mcp.install.published': 'publié',
|
||||||
'mcp.install.configureAndInstall': 'Configurer et installer',
|
'mcp.install.configureAndInstall': 'Configurer et installer',
|
||||||
|
|||||||
@@ -1376,6 +1376,7 @@ const messages: TranslationMap = {
|
|||||||
'mcp.catalog.loadMore': 'और अधिक लोड करें',
|
'mcp.catalog.loadMore': 'और अधिक लोड करें',
|
||||||
'mcp.configAssistant.title': 'कॉन्फ़िगरेशन सहायक',
|
'mcp.configAssistant.title': 'कॉन्फ़िगरेशन सहायक',
|
||||||
'mcp.configAssistant.empty': 'कॉन्फ़िगरेशन, आवश्यक एनवी वर्र्स या सेटअप चरणों के बारे में पूछें।',
|
'mcp.configAssistant.empty': 'कॉन्फ़िगरेशन, आवश्यक एनवी वर्र्स या सेटअप चरणों के बारे में पूछें।',
|
||||||
|
'mcp.configAssistant.autoPromptCta': 'चरण-दर-चरण सेटअप सहायता प्राप्त करें',
|
||||||
'mcp.configAssistant.suggestedValues': 'सुझाए गए मान:',
|
'mcp.configAssistant.suggestedValues': 'सुझाए गए मान:',
|
||||||
'mcp.configAssistant.valueHidden': '(मूल्य छिपा हुआ)',
|
'mcp.configAssistant.valueHidden': '(मूल्य छिपा हुआ)',
|
||||||
'mcp.configAssistant.applySuggested': 'सुझाए गए मान लागू करें',
|
'mcp.configAssistant.applySuggested': 'सुझाए गए मान लागू करें',
|
||||||
@@ -1521,13 +1522,15 @@ const messages: TranslationMap = {
|
|||||||
'mcp.tab.filter.registry': 'रजिस्ट्री',
|
'mcp.tab.filter.registry': 'रजिस्ट्री',
|
||||||
'mcp.tab.column.name': 'नाम',
|
'mcp.tab.column.name': 'नाम',
|
||||||
'mcp.tab.column.description': 'विवरण',
|
'mcp.tab.column.description': 'विवरण',
|
||||||
'mcp.tab.column.source': 'स्रोत',
|
'mcp.tab.column.type': 'प्रकार',
|
||||||
'mcp.tab.column.author': 'लेखक',
|
'mcp.tab.column.author': 'लेखक',
|
||||||
'mcp.tab.column.action': 'क्रिया',
|
'mcp.tab.column.action': 'क्रिया',
|
||||||
'mcp.tab.source.official': 'आधिकारिक',
|
|
||||||
'mcp.tab.source.smithery': 'Smithery',
|
|
||||||
'mcp.tab.transport.hosted': 'होस्टेड',
|
'mcp.tab.transport.hosted': 'होस्टेड',
|
||||||
'mcp.tab.transport.local': 'लोकल',
|
'mcp.tab.transport.local': 'Stdio',
|
||||||
|
'mcp.tab.transportFilter.label': 'प्रकार',
|
||||||
|
'mcp.tab.transportFilter.aria': 'ट्रांसपोर्ट के अनुसार सर्वर फ़िल्टर करें',
|
||||||
|
'mcp.tab.link.website': 'वेबसाइट',
|
||||||
|
'mcp.tab.link.repo': 'रिपॉज़िटरी',
|
||||||
'mcp.tab.transport.hostedHint':
|
'mcp.tab.transport.hostedHint':
|
||||||
'दूरस्थ सर्वर पर चलता है — इंस्टॉल करते समय साइन-इन या टोकन सेट किया जाता है',
|
'दूरस्थ सर्वर पर चलता है — इंस्टॉल करते समय साइन-इन या टोकन सेट किया जाता है',
|
||||||
'mcp.tab.transport.localHint':
|
'mcp.tab.transport.localHint':
|
||||||
@@ -1555,11 +1558,7 @@ const messages: TranslationMap = {
|
|||||||
'mcp.install.button': 'स्थापित करें',
|
'mcp.install.button': 'स्थापित करें',
|
||||||
'mcp.install.installing': 'स्थापित किया जा रहा है...',
|
'mcp.install.installing': 'स्थापित किया जा रहा है...',
|
||||||
'mcp.install.by': 'द्वारा',
|
'mcp.install.by': 'द्वारा',
|
||||||
'mcp.install.transportLocal': 'स्थानीय रूप से चलता है',
|
|
||||||
'mcp.install.transportRemote': 'क्लाउड होस्टेड',
|
|
||||||
'mcp.install.useCount': '{count} इंस्टॉलेशन',
|
'mcp.install.useCount': '{count} इंस्टॉलेशन',
|
||||||
'mcp.install.deployed': 'तैनात',
|
|
||||||
'mcp.install.requiresConfig': 'कॉन्फ़िगरेशन आवश्यक',
|
|
||||||
'mcp.install.connections': 'उपलब्ध कनेक्शन',
|
'mcp.install.connections': 'उपलब्ध कनेक्शन',
|
||||||
'mcp.install.published': 'प्रकाशित',
|
'mcp.install.published': 'प्रकाशित',
|
||||||
'mcp.install.configureAndInstall': 'कॉन्फ़िगर करें और इंस्टॉल करें',
|
'mcp.install.configureAndInstall': 'कॉन्फ़िगर करें और इंस्टॉल करें',
|
||||||
|
|||||||
@@ -1388,6 +1388,7 @@ const messages: TranslationMap = {
|
|||||||
'mcp.configAssistant.title': 'Asisten konfigurasi',
|
'mcp.configAssistant.title': 'Asisten konfigurasi',
|
||||||
'mcp.configAssistant.empty':
|
'mcp.configAssistant.empty':
|
||||||
'Tanyakan tentang konfigurasi, env vars yang diperlukan, atau langkah setup.',
|
'Tanyakan tentang konfigurasi, env vars yang diperlukan, atau langkah setup.',
|
||||||
|
'mcp.configAssistant.autoPromptCta': 'Dapatkan bantuan penyiapan langkah demi langkah',
|
||||||
'mcp.configAssistant.suggestedValues': 'Nilai yang disarankan:',
|
'mcp.configAssistant.suggestedValues': 'Nilai yang disarankan:',
|
||||||
'mcp.configAssistant.valueHidden': '(nilai tersembunyi)',
|
'mcp.configAssistant.valueHidden': '(nilai tersembunyi)',
|
||||||
'mcp.configAssistant.applySuggested': 'Terapkan nilai yang disarankan',
|
'mcp.configAssistant.applySuggested': 'Terapkan nilai yang disarankan',
|
||||||
@@ -1532,13 +1533,15 @@ const messages: TranslationMap = {
|
|||||||
'mcp.tab.filter.registry': 'Registri',
|
'mcp.tab.filter.registry': 'Registri',
|
||||||
'mcp.tab.column.name': 'Nama',
|
'mcp.tab.column.name': 'Nama',
|
||||||
'mcp.tab.column.description': 'Deskripsi',
|
'mcp.tab.column.description': 'Deskripsi',
|
||||||
'mcp.tab.column.source': 'Sumber',
|
'mcp.tab.column.type': 'Tipe',
|
||||||
'mcp.tab.column.author': 'Penulis',
|
'mcp.tab.column.author': 'Penulis',
|
||||||
'mcp.tab.column.action': 'Aksi',
|
'mcp.tab.column.action': 'Aksi',
|
||||||
'mcp.tab.source.official': 'Resmi',
|
|
||||||
'mcp.tab.source.smithery': 'Smithery',
|
|
||||||
'mcp.tab.transport.hosted': 'Terhosting',
|
'mcp.tab.transport.hosted': 'Terhosting',
|
||||||
'mcp.tab.transport.local': 'Lokal',
|
'mcp.tab.transport.local': 'Stdio',
|
||||||
|
'mcp.tab.transportFilter.label': 'Tipe',
|
||||||
|
'mcp.tab.transportFilter.aria': 'Filter server berdasarkan transport',
|
||||||
|
'mcp.tab.link.website': 'Situs web',
|
||||||
|
'mcp.tab.link.repo': 'Repositori',
|
||||||
'mcp.tab.transport.hostedHint':
|
'mcp.tab.transport.hostedHint':
|
||||||
'Berjalan di server jarak jauh — masuk atau token diatur saat memasang',
|
'Berjalan di server jarak jauh — masuk atau token diatur saat memasang',
|
||||||
'mcp.tab.transport.localHint': 'Berjalan di perangkat Anda — mungkin perlu token saat memasang',
|
'mcp.tab.transport.localHint': 'Berjalan di perangkat Anda — mungkin perlu token saat memasang',
|
||||||
@@ -1565,11 +1568,7 @@ const messages: TranslationMap = {
|
|||||||
'mcp.install.button': 'Penginstalan',
|
'mcp.install.button': 'Penginstalan',
|
||||||
'mcp.install.installing': 'Penginstalan...',
|
'mcp.install.installing': 'Penginstalan...',
|
||||||
'mcp.install.by': 'oleh',
|
'mcp.install.by': 'oleh',
|
||||||
'mcp.install.transportLocal': 'Berjalan secara lokal',
|
|
||||||
'mcp.install.transportRemote': 'Di-host di cloud',
|
|
||||||
'mcp.install.useCount': '{count} instalasi',
|
'mcp.install.useCount': '{count} instalasi',
|
||||||
'mcp.install.deployed': 'Diterapkan',
|
|
||||||
'mcp.install.requiresConfig': 'Memerlukan konfigurasi',
|
|
||||||
'mcp.install.connections': 'Koneksi tersedia',
|
'mcp.install.connections': 'Koneksi tersedia',
|
||||||
'mcp.install.published': 'diterbitkan',
|
'mcp.install.published': 'diterbitkan',
|
||||||
'mcp.install.configureAndInstall': 'Konfigurasi & instal',
|
'mcp.install.configureAndInstall': 'Konfigurasi & instal',
|
||||||
|
|||||||
@@ -1408,6 +1408,7 @@ const messages: TranslationMap = {
|
|||||||
'mcp.configAssistant.title': 'Assistente di configurazione',
|
'mcp.configAssistant.title': 'Assistente di configurazione',
|
||||||
'mcp.configAssistant.empty':
|
'mcp.configAssistant.empty':
|
||||||
"Chiedi della configurazione, delle variabili d'ambiente richieste o dei passaggi di configurazione.",
|
"Chiedi della configurazione, delle variabili d'ambiente richieste o dei passaggi di configurazione.",
|
||||||
|
'mcp.configAssistant.autoPromptCta': 'Ottieni aiuto alla configurazione passo passo',
|
||||||
'mcp.configAssistant.suggestedValues': 'Valori suggeriti:',
|
'mcp.configAssistant.suggestedValues': 'Valori suggeriti:',
|
||||||
'mcp.configAssistant.valueHidden': '(valore nascosto)',
|
'mcp.configAssistant.valueHidden': '(valore nascosto)',
|
||||||
'mcp.configAssistant.applySuggested': 'Applica i valori suggeriti',
|
'mcp.configAssistant.applySuggested': 'Applica i valori suggeriti',
|
||||||
@@ -1556,13 +1557,15 @@ const messages: TranslationMap = {
|
|||||||
'mcp.tab.filter.registry': 'Registro',
|
'mcp.tab.filter.registry': 'Registro',
|
||||||
'mcp.tab.column.name': 'Nome',
|
'mcp.tab.column.name': 'Nome',
|
||||||
'mcp.tab.column.description': 'Descrizione',
|
'mcp.tab.column.description': 'Descrizione',
|
||||||
'mcp.tab.column.source': 'Origine',
|
'mcp.tab.column.type': 'Tipo',
|
||||||
'mcp.tab.column.author': 'Autore',
|
'mcp.tab.column.author': 'Autore',
|
||||||
'mcp.tab.column.action': 'Azione',
|
'mcp.tab.column.action': 'Azione',
|
||||||
'mcp.tab.source.official': 'Ufficiale',
|
|
||||||
'mcp.tab.source.smithery': 'Smithery',
|
|
||||||
'mcp.tab.transport.hosted': 'Ospitato',
|
'mcp.tab.transport.hosted': 'Ospitato',
|
||||||
'mcp.tab.transport.local': 'Locale',
|
'mcp.tab.transport.local': 'Stdio',
|
||||||
|
'mcp.tab.transportFilter.label': 'Tipo',
|
||||||
|
'mcp.tab.transportFilter.aria': 'Filtra i server per trasporto',
|
||||||
|
'mcp.tab.link.website': 'Sito web',
|
||||||
|
'mcp.tab.link.repo': 'Repository',
|
||||||
'mcp.tab.transport.hostedHint':
|
'mcp.tab.transport.hostedHint':
|
||||||
"Funziona su un server remoto: l'accesso o il token si configura durante l'installazione",
|
"Funziona su un server remoto: l'accesso o il token si configura durante l'installazione",
|
||||||
'mcp.tab.transport.localHint':
|
'mcp.tab.transport.localHint':
|
||||||
@@ -1590,11 +1593,7 @@ const messages: TranslationMap = {
|
|||||||
'mcp.install.button': 'Installa',
|
'mcp.install.button': 'Installa',
|
||||||
'mcp.install.installing': 'Installazione...',
|
'mcp.install.installing': 'Installazione...',
|
||||||
'mcp.install.by': 'di',
|
'mcp.install.by': 'di',
|
||||||
'mcp.install.transportLocal': 'Esecuzione locale',
|
|
||||||
'mcp.install.transportRemote': 'Ospitato nel cloud',
|
|
||||||
'mcp.install.useCount': '{count} installazioni',
|
'mcp.install.useCount': '{count} installazioni',
|
||||||
'mcp.install.deployed': 'Distribuito',
|
|
||||||
'mcp.install.requiresConfig': 'Configurazione richiesta',
|
|
||||||
'mcp.install.connections': 'Connessioni disponibili',
|
'mcp.install.connections': 'Connessioni disponibili',
|
||||||
'mcp.install.published': 'pubblicato',
|
'mcp.install.published': 'pubblicato',
|
||||||
'mcp.install.configureAndInstall': 'Configura e installa',
|
'mcp.install.configureAndInstall': 'Configura e installa',
|
||||||
|
|||||||
@@ -1374,6 +1374,7 @@ const messages: TranslationMap = {
|
|||||||
'mcp.catalog.loadMore': '추가 로드',
|
'mcp.catalog.loadMore': '추가 로드',
|
||||||
'mcp.configAssistant.title': '구성 도우미',
|
'mcp.configAssistant.title': '구성 도우미',
|
||||||
'mcp.configAssistant.empty': '구성, 필수 환경 변수 또는 설정 단계에 대해 문의하세요.',
|
'mcp.configAssistant.empty': '구성, 필수 환경 변수 또는 설정 단계에 대해 문의하세요.',
|
||||||
|
'mcp.configAssistant.autoPromptCta': '단계별 설정 도움말 보기',
|
||||||
'mcp.configAssistant.suggestedValues': '제안 값:',
|
'mcp.configAssistant.suggestedValues': '제안 값:',
|
||||||
'mcp.configAssistant.valueHidden': '(값 숨김)',
|
'mcp.configAssistant.valueHidden': '(값 숨김)',
|
||||||
'mcp.configAssistant.applySuggested': '제안 값 적용',
|
'mcp.configAssistant.applySuggested': '제안 값 적용',
|
||||||
@@ -1516,13 +1517,15 @@ const messages: TranslationMap = {
|
|||||||
'mcp.tab.filter.registry': '레지스트리',
|
'mcp.tab.filter.registry': '레지스트리',
|
||||||
'mcp.tab.column.name': '이름',
|
'mcp.tab.column.name': '이름',
|
||||||
'mcp.tab.column.description': '설명',
|
'mcp.tab.column.description': '설명',
|
||||||
'mcp.tab.column.source': '출처',
|
'mcp.tab.column.type': '유형',
|
||||||
'mcp.tab.column.author': '작성자',
|
'mcp.tab.column.author': '작성자',
|
||||||
'mcp.tab.column.action': '작업',
|
'mcp.tab.column.action': '작업',
|
||||||
'mcp.tab.source.official': '공식',
|
|
||||||
'mcp.tab.source.smithery': 'Smithery',
|
|
||||||
'mcp.tab.transport.hosted': '호스팅됨',
|
'mcp.tab.transport.hosted': '호스팅됨',
|
||||||
'mcp.tab.transport.local': '로컬',
|
'mcp.tab.transport.local': 'Stdio',
|
||||||
|
'mcp.tab.transportFilter.label': '유형',
|
||||||
|
'mcp.tab.transportFilter.aria': '전송 방식으로 서버 필터링',
|
||||||
|
'mcp.tab.link.website': '웹사이트',
|
||||||
|
'mcp.tab.link.repo': '저장소',
|
||||||
'mcp.tab.transport.hostedHint': '원격 서버에서 실행 — 설치 시 로그인 또는 토큰을 설정합니다',
|
'mcp.tab.transport.hostedHint': '원격 서버에서 실행 — 설치 시 로그인 또는 토큰을 설정합니다',
|
||||||
'mcp.tab.transport.localHint': '기기에서 실행 — 설치 시 토큰이 필요할 수 있습니다',
|
'mcp.tab.transport.localHint': '기기에서 실행 — 설치 시 토큰이 필요할 수 있습니다',
|
||||||
'mcp.tab.officialBadge': '공식',
|
'mcp.tab.officialBadge': '공식',
|
||||||
@@ -1548,11 +1551,7 @@ const messages: TranslationMap = {
|
|||||||
'mcp.install.button': '설치',
|
'mcp.install.button': '설치',
|
||||||
'mcp.install.installing': '설치 중...',
|
'mcp.install.installing': '설치 중...',
|
||||||
'mcp.install.by': '제작',
|
'mcp.install.by': '제작',
|
||||||
'mcp.install.transportLocal': '로컬 실행',
|
|
||||||
'mcp.install.transportRemote': '클라우드 호스팅',
|
|
||||||
'mcp.install.useCount': '{count}회 설치',
|
'mcp.install.useCount': '{count}회 설치',
|
||||||
'mcp.install.deployed': '배포됨',
|
|
||||||
'mcp.install.requiresConfig': '구성 필요',
|
|
||||||
'mcp.install.connections': '사용 가능한 연결',
|
'mcp.install.connections': '사용 가능한 연결',
|
||||||
'mcp.install.published': '게시됨',
|
'mcp.install.published': '게시됨',
|
||||||
'mcp.install.configureAndInstall': '구성 및 설치',
|
'mcp.install.configureAndInstall': '구성 및 설치',
|
||||||
|
|||||||
@@ -1396,6 +1396,7 @@ const messages: TranslationMap = {
|
|||||||
'mcp.catalog.loadMore': 'Załaduj więcej',
|
'mcp.catalog.loadMore': 'Załaduj więcej',
|
||||||
'mcp.configAssistant.title': 'Asystent konfiguracji',
|
'mcp.configAssistant.title': 'Asystent konfiguracji',
|
||||||
'mcp.configAssistant.empty': 'Zapytaj o konfigurację, wymagane zmienne lub kroki instalacji.',
|
'mcp.configAssistant.empty': 'Zapytaj o konfigurację, wymagane zmienne lub kroki instalacji.',
|
||||||
|
'mcp.configAssistant.autoPromptCta': 'Uzyskaj pomoc w konfiguracji krok po kroku',
|
||||||
'mcp.configAssistant.suggestedValues': 'Sugerowane wartości:',
|
'mcp.configAssistant.suggestedValues': 'Sugerowane wartości:',
|
||||||
'mcp.configAssistant.valueHidden': '(wartość ukryta)',
|
'mcp.configAssistant.valueHidden': '(wartość ukryta)',
|
||||||
'mcp.configAssistant.applySuggested': 'Zastosuj sugerowane wartości',
|
'mcp.configAssistant.applySuggested': 'Zastosuj sugerowane wartości',
|
||||||
@@ -1544,13 +1545,15 @@ const messages: TranslationMap = {
|
|||||||
'mcp.tab.filter.registry': 'Rejestr',
|
'mcp.tab.filter.registry': 'Rejestr',
|
||||||
'mcp.tab.column.name': 'Nazwa',
|
'mcp.tab.column.name': 'Nazwa',
|
||||||
'mcp.tab.column.description': 'Opis',
|
'mcp.tab.column.description': 'Opis',
|
||||||
'mcp.tab.column.source': 'Źródło',
|
'mcp.tab.column.type': 'Typ',
|
||||||
'mcp.tab.column.author': 'Autor',
|
'mcp.tab.column.author': 'Autor',
|
||||||
'mcp.tab.column.action': 'Akcja',
|
'mcp.tab.column.action': 'Akcja',
|
||||||
'mcp.tab.source.official': 'Oficjalny',
|
|
||||||
'mcp.tab.source.smithery': 'Smithery',
|
|
||||||
'mcp.tab.transport.hosted': 'Hostowany',
|
'mcp.tab.transport.hosted': 'Hostowany',
|
||||||
'mcp.tab.transport.local': 'Lokalny',
|
'mcp.tab.transport.local': 'Stdio',
|
||||||
|
'mcp.tab.transportFilter.label': 'Typ',
|
||||||
|
'mcp.tab.transportFilter.aria': 'Filtruj serwery według transportu',
|
||||||
|
'mcp.tab.link.website': 'Strona',
|
||||||
|
'mcp.tab.link.repo': 'Repozytorium',
|
||||||
'mcp.tab.transport.hostedHint':
|
'mcp.tab.transport.hostedHint':
|
||||||
'Działa na zdalnym serwerze — logowanie lub token jest konfigurowany przy instalacji',
|
'Działa na zdalnym serwerze — logowanie lub token jest konfigurowany przy instalacji',
|
||||||
'mcp.tab.transport.localHint': 'Działa na Twoim urządzeniu — może wymagać tokena przy instalacji',
|
'mcp.tab.transport.localHint': 'Działa na Twoim urządzeniu — może wymagać tokena przy instalacji',
|
||||||
@@ -1577,11 +1580,7 @@ const messages: TranslationMap = {
|
|||||||
'mcp.install.button': 'Zainstaluj',
|
'mcp.install.button': 'Zainstaluj',
|
||||||
'mcp.install.installing': 'Instalowanie...',
|
'mcp.install.installing': 'Instalowanie...',
|
||||||
'mcp.install.by': 'autor:',
|
'mcp.install.by': 'autor:',
|
||||||
'mcp.install.transportLocal': 'Uruchamiane lokalnie',
|
|
||||||
'mcp.install.transportRemote': 'Hostowane w chmurze',
|
|
||||||
'mcp.install.useCount': '{count} instalacji',
|
'mcp.install.useCount': '{count} instalacji',
|
||||||
'mcp.install.deployed': 'Wdrożone',
|
|
||||||
'mcp.install.requiresConfig': 'Wymaga konfiguracji',
|
|
||||||
'mcp.install.connections': 'Dostępne połączenia',
|
'mcp.install.connections': 'Dostępne połączenia',
|
||||||
'mcp.install.published': 'opublikowane',
|
'mcp.install.published': 'opublikowane',
|
||||||
'mcp.install.configureAndInstall': 'Skonfiguruj i zainstaluj',
|
'mcp.install.configureAndInstall': 'Skonfiguruj i zainstaluj',
|
||||||
|
|||||||
@@ -1412,6 +1412,7 @@ const messages: TranslationMap = {
|
|||||||
'mcp.configAssistant.title': 'Assistente de configuração',
|
'mcp.configAssistant.title': 'Assistente de configuração',
|
||||||
'mcp.configAssistant.empty':
|
'mcp.configAssistant.empty':
|
||||||
'Pergunte sobre configuração, variáveis de ambiente necessárias ou etapas de configuração.',
|
'Pergunte sobre configuração, variáveis de ambiente necessárias ou etapas de configuração.',
|
||||||
|
'mcp.configAssistant.autoPromptCta': 'Obter ajuda de configuração passo a passo',
|
||||||
'mcp.configAssistant.suggestedValues': 'Valores sugeridos:',
|
'mcp.configAssistant.suggestedValues': 'Valores sugeridos:',
|
||||||
'mcp.configAssistant.valueHidden': '(valor oculto)',
|
'mcp.configAssistant.valueHidden': '(valor oculto)',
|
||||||
'mcp.configAssistant.applySuggested': 'Aplicar valores sugeridos',
|
'mcp.configAssistant.applySuggested': 'Aplicar valores sugeridos',
|
||||||
@@ -1561,13 +1562,15 @@ const messages: TranslationMap = {
|
|||||||
'mcp.tab.filter.registry': 'Registro',
|
'mcp.tab.filter.registry': 'Registro',
|
||||||
'mcp.tab.column.name': 'Nome',
|
'mcp.tab.column.name': 'Nome',
|
||||||
'mcp.tab.column.description': 'Descrição',
|
'mcp.tab.column.description': 'Descrição',
|
||||||
'mcp.tab.column.source': 'Origem',
|
'mcp.tab.column.type': 'Tipo',
|
||||||
'mcp.tab.column.author': 'Autor',
|
'mcp.tab.column.author': 'Autor',
|
||||||
'mcp.tab.column.action': 'Ação',
|
'mcp.tab.column.action': 'Ação',
|
||||||
'mcp.tab.source.official': 'Oficial',
|
|
||||||
'mcp.tab.source.smithery': 'Smithery',
|
|
||||||
'mcp.tab.transport.hosted': 'Hospedado',
|
'mcp.tab.transport.hosted': 'Hospedado',
|
||||||
'mcp.tab.transport.local': 'Local',
|
'mcp.tab.transport.local': 'Stdio',
|
||||||
|
'mcp.tab.transportFilter.label': 'Tipo',
|
||||||
|
'mcp.tab.transportFilter.aria': 'Filtrar servidores por transporte',
|
||||||
|
'mcp.tab.link.website': 'Site',
|
||||||
|
'mcp.tab.link.repo': 'Repositório',
|
||||||
'mcp.tab.transport.hostedHint':
|
'mcp.tab.transport.hostedHint':
|
||||||
'Executa em um servidor remoto — login ou token é configurado ao instalar',
|
'Executa em um servidor remoto — login ou token é configurado ao instalar',
|
||||||
'mcp.tab.transport.localHint':
|
'mcp.tab.transport.localHint':
|
||||||
@@ -1595,11 +1598,7 @@ const messages: TranslationMap = {
|
|||||||
'mcp.install.button': 'Instalação',
|
'mcp.install.button': 'Instalação',
|
||||||
'mcp.install.installing': 'Instalando...',
|
'mcp.install.installing': 'Instalando...',
|
||||||
'mcp.install.by': 'por',
|
'mcp.install.by': 'por',
|
||||||
'mcp.install.transportLocal': 'Execução local',
|
|
||||||
'mcp.install.transportRemote': 'Hospedado na nuvem',
|
|
||||||
'mcp.install.useCount': '{count} instalações',
|
'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.connections': 'Conexões disponíveis',
|
||||||
'mcp.install.published': 'publicado',
|
'mcp.install.published': 'publicado',
|
||||||
'mcp.install.configureAndInstall': 'Configurar e instalar',
|
'mcp.install.configureAndInstall': 'Configurar e instalar',
|
||||||
|
|||||||
@@ -1396,6 +1396,7 @@ const messages: TranslationMap = {
|
|||||||
'mcp.configAssistant.title': 'Помощник по настройке',
|
'mcp.configAssistant.title': 'Помощник по настройке',
|
||||||
'mcp.configAssistant.empty':
|
'mcp.configAssistant.empty':
|
||||||
'Спросите о конфигурации, необходимых переменных окружения или этапах настройки.',
|
'Спросите о конфигурации, необходимых переменных окружения или этапах настройки.',
|
||||||
|
'mcp.configAssistant.autoPromptCta': 'Пошаговая помощь по настройке',
|
||||||
'mcp.configAssistant.suggestedValues': 'Рекомендуемые значения:',
|
'mcp.configAssistant.suggestedValues': 'Рекомендуемые значения:',
|
||||||
'mcp.configAssistant.valueHidden': '(значение скрыто)',
|
'mcp.configAssistant.valueHidden': '(значение скрыто)',
|
||||||
'mcp.configAssistant.applySuggested': 'Примените предложенные значения.',
|
'mcp.configAssistant.applySuggested': 'Примените предложенные значения.',
|
||||||
@@ -1541,13 +1542,15 @@ const messages: TranslationMap = {
|
|||||||
'mcp.tab.filter.registry': 'Реестр',
|
'mcp.tab.filter.registry': 'Реестр',
|
||||||
'mcp.tab.column.name': 'Название',
|
'mcp.tab.column.name': 'Название',
|
||||||
'mcp.tab.column.description': 'Описание',
|
'mcp.tab.column.description': 'Описание',
|
||||||
'mcp.tab.column.source': 'Источник',
|
'mcp.tab.column.type': 'Тип',
|
||||||
'mcp.tab.column.author': 'Автор',
|
'mcp.tab.column.author': 'Автор',
|
||||||
'mcp.tab.column.action': 'Действие',
|
'mcp.tab.column.action': 'Действие',
|
||||||
'mcp.tab.source.official': 'Официальный',
|
|
||||||
'mcp.tab.source.smithery': 'Smithery',
|
|
||||||
'mcp.tab.transport.hosted': 'Размещённый',
|
'mcp.tab.transport.hosted': 'Размещённый',
|
||||||
'mcp.tab.transport.local': 'Локальный',
|
'mcp.tab.transport.local': 'Stdio',
|
||||||
|
'mcp.tab.transportFilter.label': 'Тип',
|
||||||
|
'mcp.tab.transportFilter.aria': 'Фильтр серверов по транспорту',
|
||||||
|
'mcp.tab.link.website': 'Сайт',
|
||||||
|
'mcp.tab.link.repo': 'Репозиторий',
|
||||||
'mcp.tab.transport.hostedHint':
|
'mcp.tab.transport.hostedHint':
|
||||||
'Работает на удалённом сервере — вход или токен настраивается при установке',
|
'Работает на удалённом сервере — вход или токен настраивается при установке',
|
||||||
'mcp.tab.transport.localHint':
|
'mcp.tab.transport.localHint':
|
||||||
@@ -1575,11 +1578,7 @@ const messages: TranslationMap = {
|
|||||||
'mcp.install.button': 'Установить',
|
'mcp.install.button': 'Установить',
|
||||||
'mcp.install.installing': 'Установка...',
|
'mcp.install.installing': 'Установка...',
|
||||||
'mcp.install.by': 'от',
|
'mcp.install.by': 'от',
|
||||||
'mcp.install.transportLocal': 'Локальный запуск',
|
|
||||||
'mcp.install.transportRemote': 'Облачный хостинг',
|
|
||||||
'mcp.install.useCount': '{count} установок',
|
'mcp.install.useCount': '{count} установок',
|
||||||
'mcp.install.deployed': 'Развёрнуто',
|
|
||||||
'mcp.install.requiresConfig': 'Требуется настройка',
|
|
||||||
'mcp.install.connections': 'Доступные подключения',
|
'mcp.install.connections': 'Доступные подключения',
|
||||||
'mcp.install.published': 'опубликовано',
|
'mcp.install.published': 'опубликовано',
|
||||||
'mcp.install.configureAndInstall': 'Настроить и установить',
|
'mcp.install.configureAndInstall': 'Настроить и установить',
|
||||||
|
|||||||
@@ -1313,6 +1313,7 @@ const messages: TranslationMap = {
|
|||||||
'mcp.catalog.loadMore': '加载更多',
|
'mcp.catalog.loadMore': '加载更多',
|
||||||
'mcp.configAssistant.title': '配置助手',
|
'mcp.configAssistant.title': '配置助手',
|
||||||
'mcp.configAssistant.empty': '询问配置、所需的环境变量或设置步骤。',
|
'mcp.configAssistant.empty': '询问配置、所需的环境变量或设置步骤。',
|
||||||
|
'mcp.configAssistant.autoPromptCta': '获取分步设置帮助',
|
||||||
'mcp.configAssistant.suggestedValues': '建议值:',
|
'mcp.configAssistant.suggestedValues': '建议值:',
|
||||||
'mcp.configAssistant.valueHidden': '(隐藏值)',
|
'mcp.configAssistant.valueHidden': '(隐藏值)',
|
||||||
'mcp.configAssistant.applySuggested': '应用建议值',
|
'mcp.configAssistant.applySuggested': '应用建议值',
|
||||||
@@ -1452,13 +1453,15 @@ const messages: TranslationMap = {
|
|||||||
'mcp.tab.filter.registry': '注册表',
|
'mcp.tab.filter.registry': '注册表',
|
||||||
'mcp.tab.column.name': '名称',
|
'mcp.tab.column.name': '名称',
|
||||||
'mcp.tab.column.description': '描述',
|
'mcp.tab.column.description': '描述',
|
||||||
'mcp.tab.column.source': '来源',
|
'mcp.tab.column.type': '类型',
|
||||||
'mcp.tab.column.author': '作者',
|
'mcp.tab.column.author': '作者',
|
||||||
'mcp.tab.column.action': '操作',
|
'mcp.tab.column.action': '操作',
|
||||||
'mcp.tab.source.official': '官方',
|
|
||||||
'mcp.tab.source.smithery': 'Smithery',
|
|
||||||
'mcp.tab.transport.hosted': '托管',
|
'mcp.tab.transport.hosted': '托管',
|
||||||
'mcp.tab.transport.local': '本地',
|
'mcp.tab.transport.local': 'Stdio',
|
||||||
|
'mcp.tab.transportFilter.label': '类型',
|
||||||
|
'mcp.tab.transportFilter.aria': '按传输方式筛选服务器',
|
||||||
|
'mcp.tab.link.website': '网站',
|
||||||
|
'mcp.tab.link.repo': '仓库',
|
||||||
'mcp.tab.transport.hostedHint': '在远程服务器上运行 — 安装时设置登录或令牌',
|
'mcp.tab.transport.hostedHint': '在远程服务器上运行 — 安装时设置登录或令牌',
|
||||||
'mcp.tab.transport.localHint': '在您的设备上运行 — 安装时可能需要令牌',
|
'mcp.tab.transport.localHint': '在您的设备上运行 — 安装时可能需要令牌',
|
||||||
'mcp.tab.officialBadge': '官方',
|
'mcp.tab.officialBadge': '官方',
|
||||||
@@ -1484,11 +1487,7 @@ const messages: TranslationMap = {
|
|||||||
'mcp.install.button': '安装',
|
'mcp.install.button': '安装',
|
||||||
'mcp.install.installing': '正在安装...',
|
'mcp.install.installing': '正在安装...',
|
||||||
'mcp.install.by': '作者',
|
'mcp.install.by': '作者',
|
||||||
'mcp.install.transportLocal': '本地运行',
|
|
||||||
'mcp.install.transportRemote': '云端托管',
|
|
||||||
'mcp.install.useCount': '{count} 次安装',
|
'mcp.install.useCount': '{count} 次安装',
|
||||||
'mcp.install.deployed': '已部署',
|
|
||||||
'mcp.install.requiresConfig': '需要配置',
|
|
||||||
'mcp.install.connections': '可用连接',
|
'mcp.install.connections': '可用连接',
|
||||||
'mcp.install.published': '已发布',
|
'mcp.install.published': '已发布',
|
||||||
'mcp.install.configureAndInstall': '配置并安装',
|
'mcp.install.configureAndInstall': '配置并安装',
|
||||||
|
|||||||
@@ -107,6 +107,8 @@ export const mcpClientsApi = {
|
|||||||
/** Search the Smithery registry. Returns paged results. */
|
/** Search the Smithery registry. Returns paged results. */
|
||||||
registrySearch: async (params: {
|
registrySearch: async (params: {
|
||||||
query?: string;
|
query?: string;
|
||||||
|
/** Transport filter: 'stdio' | 'hosted' | 'all' (omit for all). */
|
||||||
|
transport?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
page_size?: number;
|
page_size?: number;
|
||||||
}): Promise<RegistrySearchResult> => {
|
}): Promise<RegistrySearchResult> => {
|
||||||
|
|||||||
@@ -75,6 +75,13 @@ const STATUS_CONNECTED = {
|
|||||||
tool_count: 5,
|
tool_count: 5,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Tools a connected server exposes — returned by the mocked connect so the
|
||||||
|
// detail's tool list (and the execution playground) have something to drive.
|
||||||
|
const MOCK_TOOLS = [
|
||||||
|
{ name: 'create_memory', description: 'Create a memory', input_schema: {} },
|
||||||
|
{ name: 'list_memories', description: 'List all memories', input_schema: {} },
|
||||||
|
];
|
||||||
|
|
||||||
const GITHUB_DETAIL = {
|
const GITHUB_DETAIL = {
|
||||||
...REGISTRY_SERVERS[1],
|
...REGISTRY_SERVERS[1],
|
||||||
connections: [{ type: 'stdio', published: true }],
|
connections: [{ type: 'stdio', published: true }],
|
||||||
@@ -100,7 +107,9 @@ const GITHUB_INSTALLED = {
|
|||||||
|
|
||||||
interface MockState {
|
interface MockState {
|
||||||
installed: (typeof INSTALLED_DEFAULT)[];
|
installed: (typeof INSTALLED_DEFAULT)[];
|
||||||
statuses: (typeof STATUS_CONNECTED)[];
|
// `status` is broadened to a string so tests can seed non-connected states
|
||||||
|
// (e.g. `error`) that keep the status poll active.
|
||||||
|
statuses: Array<Omit<typeof STATUS_CONNECTED, 'status'> & { status: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function rpcOk(id: number, result: unknown) {
|
function rpcOk(id: number, result: unknown) {
|
||||||
@@ -197,15 +206,47 @@ async function setupMockRpc(page: Page, state: MockState) {
|
|||||||
state.installed.push(GITHUB_INSTALLED);
|
state.installed.push(GITHUB_INSTALLED);
|
||||||
return route.fulfill(rpcOk(id, { server: GITHUB_INSTALLED }));
|
return route.fulfill(rpcOk(id, { server: GITHUB_INSTALLED }));
|
||||||
|
|
||||||
case 'openhuman.mcp_clients_connect':
|
// Auth probe for the upfront connect modal — these test servers need no
|
||||||
state.statuses.push({
|
// credentials, so report `none` and the modal shows a single Connect button.
|
||||||
server_id: params.server_id,
|
case 'openhuman.mcp_clients_detect_auth':
|
||||||
qualified_name: 'io.github.test/github-tools',
|
return route.fulfill(rpcOk(id, { kind: 'none' }));
|
||||||
display_name: 'GitHub Tools',
|
|
||||||
status: 'connected',
|
case 'openhuman.mcp_clients_connect': {
|
||||||
tool_count: 3,
|
const sid = params.server_id;
|
||||||
});
|
const inst = state.installed.find(s => s.server_id === sid);
|
||||||
return route.fulfill(rpcOk(id, { status: 'connected', tools: [] }));
|
// Reject unknown server ids so the test can't pass while wired to the
|
||||||
|
// wrong server.
|
||||||
|
if (!inst) {
|
||||||
|
return route.fulfill(rpcError(id, `server not installed: ${sid}`));
|
||||||
|
}
|
||||||
|
// Mark the server connected for subsequent status polls and hand back
|
||||||
|
// its tool list (what `onConnected` feeds into the detail's tool list).
|
||||||
|
state.statuses = [
|
||||||
|
...state.statuses.filter(s => s.server_id !== sid),
|
||||||
|
{
|
||||||
|
server_id: sid,
|
||||||
|
qualified_name: inst.qualified_name,
|
||||||
|
display_name: inst.display_name,
|
||||||
|
status: 'connected',
|
||||||
|
tool_count: MOCK_TOOLS.length,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return route.fulfill(rpcOk(id, { status: 'connected', tools: MOCK_TOOLS }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool execution — what the playground's "Run tool" calls. Unknown tools
|
||||||
|
// come back as a tool error so the spec can't pass on a wrong tool name.
|
||||||
|
case 'openhuman.mcp_clients_tool_call': {
|
||||||
|
const known = MOCK_TOOLS.some(t => t.name === params.tool_name);
|
||||||
|
if (!known) {
|
||||||
|
return route.fulfill(
|
||||||
|
rpcOk(id, { result: `unknown tool: ${params.tool_name}`, is_error: true })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return route.fulfill(
|
||||||
|
rpcOk(id, { result: `ran ${params.tool_name}: memory created id=42`, is_error: false })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
case 'openhuman.mcp_clients_disconnect':
|
case 'openhuman.mcp_clients_disconnect':
|
||||||
state.statuses = state.statuses.filter(s => s.server_id !== params.server_id);
|
state.statuses = state.statuses.filter(s => s.server_id !== params.server_id);
|
||||||
@@ -470,6 +511,69 @@ test.describe('MCP Tab — Manage & Uninstall Lifecycle', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe('MCP Tab — Connect & Tool Execution', () => {
|
||||||
|
let state: MockState;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
// Seed the installed server in `error` status: the detail offers a Connect
|
||||||
|
// affordance AND the status poll stays active (error is non-terminal), so
|
||||||
|
// the status flips to connected once the modal connects.
|
||||||
|
state = {
|
||||||
|
installed: [makeInstalledServer()],
|
||||||
|
statuses: [
|
||||||
|
{
|
||||||
|
server_id: 'srv_installed_1',
|
||||||
|
qualified_name: 'io.github.test/memory-server',
|
||||||
|
display_name: 'Memory Server',
|
||||||
|
status: 'error',
|
||||||
|
tool_count: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
await seedLocalStorage(page);
|
||||||
|
await setupMockRpc(page, state);
|
||||||
|
await navigateToMcpTab(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('connect a server, then run one of its tools and see the result', async ({ page }) => {
|
||||||
|
// Open the installed server's detail view.
|
||||||
|
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("Go back")')).toBeVisible({ timeout: 5_000 });
|
||||||
|
|
||||||
|
// Connect via the upfront auth modal (no-auth server → a single Connect
|
||||||
|
// button). `exact` avoids the "Connections" sidebar nav button.
|
||||||
|
await page.getByRole('button', { name: 'Connect', exact: true }).click();
|
||||||
|
const connectDialog = page.getByRole('dialog');
|
||||||
|
await expect(connectDialog).toBeVisible({ timeout: 5_000 });
|
||||||
|
await connectDialog.getByRole('button', { name: /^Connect$/ }).click();
|
||||||
|
|
||||||
|
// The status poll flips the server to connected → its (collapsed) tool list
|
||||||
|
// appears. Expand it, then open the execution playground for a tool. This is
|
||||||
|
// the connect → tool step of the install→connect→tool path.
|
||||||
|
const toolsToggle = page.getByRole('button', { name: /tools available/ });
|
||||||
|
await expect(toolsToggle).toBeVisible({ timeout: 15_000 });
|
||||||
|
await toolsToggle.click();
|
||||||
|
|
||||||
|
const tryButton = page.getByRole('button', {
|
||||||
|
name: 'Open execution playground for create_memory',
|
||||||
|
});
|
||||||
|
await expect(tryButton).toBeVisible({ timeout: 5_000 });
|
||||||
|
await tryButton.click();
|
||||||
|
|
||||||
|
// The Tool Execution Playground opens; run the tool and assert the result
|
||||||
|
// surfaced from the mocked `mcp_clients_tool_call`.
|
||||||
|
const playground = page.getByRole('dialog');
|
||||||
|
await expect(playground.getByText('Run create_memory')).toBeVisible({ timeout: 5_000 });
|
||||||
|
await playground.getByRole('button', { name: 'Run tool' }).click();
|
||||||
|
await expect(page.getByTestId('mcp-playground-result')).toContainText('memory created id=42', {
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test.describe('MCP Tab — Empty & Edge States', () => {
|
test.describe('MCP Tab — Empty & Edge States', () => {
|
||||||
test('empty installed list shows appropriate message', async ({ page }) => {
|
test('empty installed list shows appropriate message', async ({ page }) => {
|
||||||
const state: MockState = { installed: [], statuses: [] };
|
const state: MockState = { installed: [], statuses: [] };
|
||||||
|
|||||||
@@ -37,13 +37,13 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil
|
|||||||
|
|
||||||
### 0.2 Installation & Launch
|
### 0.2 Installation & Launch
|
||||||
|
|
||||||
| ID | Feature | Layer | Test path(s) | Status | Notes |
|
| ID | Feature | Layer | Test path(s) | Status | Notes |
|
||||||
| ----- | ------------------------------- | ----- | -------------------- | ------ | ---------------------------------------- |
|
| ----- | ------------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 0.2.1 | DMG Installation Flow | MS | release-manual-smoke | 🚫 | OS-level Finder drag |
|
| 0.2.1 | DMG Installation Flow | MS | release-manual-smoke | 🚫 | OS-level Finder drag |
|
||||||
| 0.2.2 | Gatekeeper Validation | MS | release-manual-smoke | 🚫 | OS-level signature check |
|
| 0.2.2 | Gatekeeper Validation | MS | release-manual-smoke | 🚫 | OS-level signature check |
|
||||||
| 0.2.3 | Code Signing Verification | MS | release-manual-smoke | 🚫 | `codesign --verify` capture in checklist |
|
| 0.2.3 | Code Signing Verification | MS | release-manual-smoke | 🚫 | `codesign --verify` capture in checklist |
|
||||||
| 0.2.4 | First Launch Permissions Prompt | MS | release-manual-smoke | 🚫 | TCC prompts non-driver-automatable |
|
| 0.2.4 | First Launch Permissions Prompt | MS | release-manual-smoke | 🚫 | TCC prompts non-driver-automatable |
|
||||||
| 0.2.5 | First-Run Harness Init (Python/spaCy/Node) | RU+RI+VU | `src/openhuman/harness_init/*` (`#[cfg(test)]`), `src/openhuman/runtime_python/downloader_tests.rs`, `tests/json_rpc_e2e.rs`, `app/src/services/harnessInitService.test.ts`, `app/src/components/InitProgressScreen/InitProgressScreen.test.tsx` | ✅ | Eager startup provisioning + `harness_init_status`/`_run` RPC + blocking init overlay; managed CPython pinned to 3.13.x. Live download/model fetch is `MS` (network) |
|
| 0.2.5 | First-Run Harness Init (Python/spaCy/Node) | RU+RI+VU | `src/openhuman/harness_init/*` (`#[cfg(test)]`), `src/openhuman/runtime_python/downloader_tests.rs`, `tests/json_rpc_e2e.rs`, `app/src/services/harnessInitService.test.ts`, `app/src/components/InitProgressScreen/InitProgressScreen.test.tsx` | ✅ | Eager startup provisioning + `harness_init_status`/`_run` RPC + blocking init overlay; managed CPython pinned to 3.13.x. Live download/model fetch is `MS` (network) |
|
||||||
|
|
||||||
### 0.3 Updates & Reinstallation
|
### 0.3 Updates & Reinstallation
|
||||||
|
|
||||||
@@ -177,14 +177,14 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil
|
|||||||
|
|
||||||
### 4.2 Messaging
|
### 4.2 Messaging
|
||||||
|
|
||||||
| ID | Feature | Layer | Test path(s) | Status | Notes |
|
| ID | Feature | Layer | Test path(s) | Status | Notes |
|
||||||
| ----- | -------------------------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----- | ------------------------------------------------------------------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 4.2.1 | User Message Handling | WD+RI | `conversations-web-channel-flow.spec.ts`, `tests/json_rpc_e2e.rs` | ✅ | |
|
| 4.2.1 | User Message Handling | WD+RI | `conversations-web-channel-flow.spec.ts`, `tests/json_rpc_e2e.rs` | ✅ | |
|
||||||
| 4.2.2 | AI Response Generation | WD | `agent-review.spec.ts` | ✅ | Mock LLM |
|
| 4.2.2 | AI Response Generation | WD | `agent-review.spec.ts` | ✅ | Mock LLM |
|
||||||
| 4.2.3 | Streaming Responses | RI | `tests/json_rpc_e2e.rs`, `tests/agent_harness_e2e.rs` | ✅ | `tests/agent_harness_e2e.rs` adds provider-level SSE tool-arg accumulation (chunked args reassembled + parsed) and engine-level delta forwarding (#3471) |
|
| 4.2.3 | Streaming Responses | RI | `tests/json_rpc_e2e.rs`, `tests/agent_harness_e2e.rs` | ✅ | `tests/agent_harness_e2e.rs` adds provider-level SSE tool-arg accumulation (chunked args reassembled + parsed) and engine-level delta forwarding (#3471) |
|
||||||
| 4.2.4 | Parallel inference (cross-thread + within-thread forked turns) | RU+VU | `src/openhuman/channels/providers/web_tests.rs`, `app/src/store/__tests__/chatRuntimeSlice.test.ts`, `app/src/providers/__tests__/ChatRuntimeProvider.test.tsx` | 🟡 | Concurrent same-/cross-thread dispatch, cooperative `CancellationToken` teardown, and parallel-lane stream routing covered; dedicated WD E2E is a follow-up |
|
| 4.2.4 | Parallel inference (cross-thread + within-thread forked turns) | RU+VU | `src/openhuman/channels/providers/web_tests.rs`, `app/src/store/__tests__/chatRuntimeSlice.test.ts`, `app/src/providers/__tests__/ChatRuntimeProvider.test.tsx` | 🟡 | Concurrent same-/cross-thread dispatch, cooperative `CancellationToken` teardown, and parallel-lane stream routing covered; dedicated WD E2E is a follow-up |
|
||||||
| 4.2.5 | Per-thread todo list (plan strip above composer) | RU+VU+WD | `src/openhuman/agent/tools/todo.rs`, `app/src/pages/conversations/components/ThreadTodoStrip.test.tsx`, `app/test/e2e/specs/chat-thread-todo-strip.spec.ts` | ✅ | Read-only thread-scoped todo strip fed by `task_board_updated`; agent `todo` tool guidance + thread binding; E2E drives a `todo` tool call and asserts the card renders |
|
| 4.2.5 | Per-thread todo list (plan strip above composer) | RU+VU+WD | `src/openhuman/agent/tools/todo.rs`, `app/src/pages/conversations/components/ThreadTodoStrip.test.tsx`, `app/test/e2e/specs/chat-thread-todo-strip.spec.ts` | ✅ | Read-only thread-scoped todo strip fed by `task_board_updated`; agent `todo` tool guidance + thread binding; E2E drives a `todo` tool call and asserts the card renders |
|
||||||
| 4.2.6 | Background-activity panel (chat-header Background tasks button) | VU+WD | `app/src/pages/conversations/hooks/useBackgroundActivity.test.ts`, `app/src/pages/conversations/components/__tests__/BackgroundActivityRows.test.tsx`, `app/test/e2e/specs/chat-background-activity-panel.spec.ts` | ✅ | View-only panel surfacing this chat's async sub-agents + global cron jobs, subconscious/heartbeat status, and memory syncing; freshness-only "Syncing now" labeling; E2E opens the panel and asserts its sections render and close |
|
| 4.2.6 | Background-activity panel (chat-header Background tasks button) | VU+WD | `app/src/pages/conversations/hooks/useBackgroundActivity.test.ts`, `app/src/pages/conversations/components/__tests__/BackgroundActivityRows.test.tsx`, `app/test/e2e/specs/chat-background-activity-panel.spec.ts` | ✅ | View-only panel surfacing this chat's async sub-agents + global cron jobs, subconscious/heartbeat status, and memory syncing; freshness-only "Syncing now" labeling; E2E opens the panel and asserts its sections render and close |
|
||||||
| 4.2.7 | Plan-mode review (Approve / Reject / Send-feedback before execute) | RU+RI+VU | `src/openhuman/plan_review/gate.rs`, `src/openhuman/plan_review/tool.rs`, `src/openhuman/plan_review/schemas.rs`, `tests/json_rpc_e2e.rs`, `app/src/pages/conversations/components/PlanReviewCard.test.tsx`, `app/src/pages/__tests__/Conversations.render.test.tsx` | ✅ | Interactive turns call `request_plan_review`, which parks the LIVE turn on the in-memory `PlanReviewGate` (oneshot) until the user decides; `plan_review_request` socket event drives `PlanReviewCard`, which resolves via `openhuman.plan_review_decide` (approve resumes-and-executes / reject resumes-and-stops / revise resumes-with-feedback). RU covers gate park/resolve/timeout + tool auto-approve + parking; RI covers the decide RPC; VU covers the card + provider wiring. WD E2E (agent-driven park flow) tracked as follow-up |
|
| 4.2.7 | Plan-mode review (Approve / Reject / Send-feedback before execute) | RU+RI+VU | `src/openhuman/plan_review/gate.rs`, `src/openhuman/plan_review/tool.rs`, `src/openhuman/plan_review/schemas.rs`, `tests/json_rpc_e2e.rs`, `app/src/pages/conversations/components/PlanReviewCard.test.tsx`, `app/src/pages/__tests__/Conversations.render.test.tsx` | ✅ | Interactive turns call `request_plan_review`, which parks the LIVE turn on the in-memory `PlanReviewGate` (oneshot) until the user decides; `plan_review_request` socket event drives `PlanReviewCard`, which resolves via `openhuman.plan_review_decide` (approve resumes-and-executes / reject resumes-and-stops / revise resumes-with-feedback). RU covers gate park/resolve/timeout + tool auto-approve + parking; RI covers the decide RPC; VU covers the card + provider wiring. WD E2E (agent-driven park flow) tracked as follow-up |
|
||||||
|
|
||||||
| 4.2.8 | Composer attachments (image / video / document; drag-drop + paste) | VU | `app/src/lib/attachments.test.ts`, `app/src/components/chat/__tests__/ChatComposer.test.tsx`, `app/src/pages/__tests__/Conversations.attachments.test.tsx` | 🟡 | Attach affordance gated on the resolved vision tier (images/video need vision; documents flow on any model); video is sampled into still frames client-side and forwarded through the existing `[IMAGE:]` vision path; drag-drop + clipboard-paste reuse the picker ingest. VU covers MIME/kind/limits/marker building + drag-drop + paste; real video decode and the frames→vision round-trip are manual-smoke only (jsdom has no video codec). WD E2E is a follow-up |
|
| 4.2.8 | Composer attachments (image / video / document; drag-drop + paste) | VU | `app/src/lib/attachments.test.ts`, `app/src/components/chat/__tests__/ChatComposer.test.tsx`, `app/src/pages/__tests__/Conversations.attachments.test.tsx` | 🟡 | Attach affordance gated on the resolved vision tier (images/video need vision; documents flow on any model); video is sampled into still frames client-side and forwarded through the existing `[IMAGE:]` vision path; drag-drop + clipboard-paste reuse the picker ingest. VU covers MIME/kind/limits/marker building + drag-drop + paste; real video decode and the frames→vision round-trip are manual-smoke only (jsdom has no video codec). WD E2E is a follow-up |
|
||||||
@@ -458,22 +458,22 @@ End-to-end coverage of the agent harness via the web-chat RPC surface against an
|
|||||||
|
|
||||||
### 11.1 Analysis Engine
|
### 11.1 Analysis Engine
|
||||||
|
|
||||||
| ID | Feature | Layer | Test path(s) | Status | Notes |
|
| ID | Feature | Layer | Test path(s) | Status | Notes |
|
||||||
| ------- | ------------------------------------------------------------------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
| ------- | ------------------------------------------------------------------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 11.1.1 | Multi-Source Analysis | RI | `tests/memory_graph_sync_e2e.rs` | 🟡 | Frontend trigger untested |
|
| 11.1.1 | Multi-Source Analysis | RI | `tests/memory_graph_sync_e2e.rs` | 🟡 | Frontend trigger untested |
|
||||||
| 11.1.2 | Actionable Item Extraction | VU | `app/src/components/intelligence/__tests__/utils.test.ts` | ✅ | Was ❌ |
|
| 11.1.2 | Actionable Item Extraction | VU | `app/src/components/intelligence/__tests__/utils.test.ts` | ✅ | Was ❌ |
|
||||||
| 11.1.3 | Analyze Trigger | WD | `app/test/e2e/specs/insights-dashboard.spec.ts` mounts the route; explicit analyze-handler invocation TBD | 🟡 | Route mounts and search/filter UI assert — full analyze trigger flow tracked as follow-up |
|
| 11.1.3 | Analyze Trigger | WD | `app/test/e2e/specs/insights-dashboard.spec.ts` mounts the route; explicit analyze-handler invocation TBD | 🟡 | Route mounts and search/filter UI assert — full analyze trigger flow tracked as follow-up |
|
||||||
| 11.1.4 | MCP server (stdio + HTTP) | RU | `src/openhuman/mcp_server/` | ✅ | Stdio framing plus Streamable HTTP/SSE session lifecycle; `McpHttpClient` round-trip tests |
|
| 11.1.4 | MCP server (stdio + HTTP) | RU | `src/openhuman/mcp_server/` | ✅ | Stdio framing plus Streamable HTTP/SSE session lifecycle; `McpHttpClient` round-trip tests |
|
||||||
| 11.1.5 | Global tool registry | RI | `src/openhuman/tool_registry/`, `tests/json_rpc_e2e.rs`, `tests/domain_modules_e2e.rs`, `tests/worker_b_domain_e2e.rs` | ✅ | Read-only MCP/controller discovery with routes, schemas, version, allowed agents, and health |
|
| 11.1.5 | Global tool registry | RI | `src/openhuman/tool_registry/`, `tests/json_rpc_e2e.rs`, `tests/domain_modules_e2e.rs`, `tests/worker_b_domain_e2e.rs` | ✅ | Read-only MCP/controller discovery with routes, schemas, version, allowed agents, and health |
|
||||||
| 11.1.6 | SearXNG MCP search | RU | `src/openhuman/integrations/searxng.rs`, `src/openhuman/mcp_server/tools.rs`, `src/openhuman/tools/schemas.rs` | ✅ | Self-hosted search config, normalized results, MCP argument validation, and mocked HTTP execution |
|
| 11.1.6 | SearXNG MCP search | RU | `src/openhuman/integrations/searxng.rs`, `src/openhuman/mcp_server/tools.rs`, `src/openhuman/tools/schemas.rs` | ✅ | Self-hosted search config, normalized results, MCP argument validation, and mocked HTTP execution |
|
||||||
| 11.1.7 | Bundled prompt resources | RU | `src/openhuman/mcp_server/resources.rs`, `src/openhuman/mcp_server/protocol.rs` | ✅ | `resources/list` catalog + `resources/read` happy path, -32002 unknown URI, -32602 missing param, catalog-mirrors-BUILTINS parity test |
|
| 11.1.7 | Bundled prompt resources | RU | `src/openhuman/mcp_server/resources.rs`, `src/openhuman/mcp_server/protocol.rs` | ✅ | `resources/list` catalog + `resources/read` happy path, -32002 unknown URI, -32602 missing param, catalog-mirrors-BUILTINS parity test |
|
||||||
| 11.1.8 | Resource templates list | RU | `src/openhuman/mcp_server/resources.rs`, `src/openhuman/mcp_server/protocol.rs` | ✅ | `resources/templates/list` returns `{resourceTemplates: []}` (static catalog), tolerates unknown/cursor params |
|
| 11.1.8 | Resource templates list | RU | `src/openhuman/mcp_server/resources.rs`, `src/openhuman/mcp_server/protocol.rs` | ✅ | `resources/templates/list` returns `{resourceTemplates: []}` (static catalog), tolerates unknown/cursor params |
|
||||||
| 11.1.10 | MCP registry install→connect→tool_call | RI | `tests/json_rpc_e2e.rs` (`mcp_clients_install_connect_tool_call_happy_path`), `tests/mcp_registry_e2e.rs`, `src/openhuman/mcp_registry/setup_ops.rs` (#3039) | ✅ | HTTP-RPC happy path install→connect→tool_call→update_env against `test-mcp-stub`; transport-aware install (stdio + http_remote) via `build_install_transport` |
|
| 11.1.10 | MCP registry install→connect→tool_call | RI | `tests/json_rpc_e2e.rs` (`mcp_clients_install_connect_tool_call_happy_path`), `tests/mcp_registry_e2e.rs`, `src/openhuman/mcp_registry/setup_ops.rs`, `app/test/playwright/specs/mcp-tab-flow.spec.ts` (#3039, #4272) | ✅ | HTTP-RPC happy path install→connect→tool_call→update_env against `test-mcp-stub`; transport-aware install (stdio + http_remote) via `build_install_transport`; UI-level Playwright flow drives browse→install→connect→run-tool (#4272) |
|
||||||
| 11.1.11 | MCP env reconfigure + registry creds | RI/VU | `tests/json_rpc_e2e.rs` (`mcp_clients_registry_settings_roundtrip`), `src/openhuman/mcp_registry/registries/mcp_official.rs`, `app/src/components/channels/mcp/InstalledServerDetail.test.tsx` (#3039) | ✅ | `update_env` persist+reconnect; `registry_settings` get/set with secrets write-only (config-first, env-fallback); reconfigure form validation |
|
| 11.1.11 | MCP env reconfigure + registry creds | RI/VU | `tests/json_rpc_e2e.rs` (`mcp_clients_registry_settings_roundtrip`), `src/openhuman/mcp_registry/registries/mcp_official.rs`, `app/src/components/channels/mcp/InstalledServerDetail.test.tsx` (#3039) | ✅ | `update_env` persist+reconnect; `registry_settings` get/set with secrets write-only (config-first, env-fallback); reconfigure form validation |
|
||||||
| 11.1.12 | MCP UI surface + setup-agent client | VU | `app/src/components/channels/mcp/InstallDialog.test.tsx`, `app/src/services/api/mcpClientsApi.test.ts`, `app/src/services/api/mcpSetupApi.test.ts` (#3039) | ✅ | Skills `?tab=mcp` renders `McpServersTab` (not Coming Soon); auto-connect on install (best-effort); typed `mcpSetupApi` wrapper |
|
| 11.1.12 | MCP UI surface + setup-agent client | VU/RU | `app/src/components/channels/mcp/InstallDialog.test.tsx`, `app/src/components/channels/mcp/McpServersTab.test.tsx`, `app/src/services/api/mcpClientsApi.test.ts`, `app/src/services/api/mcpSetupApi.test.ts`, `src/openhuman/mcp_registry/{curation,registry,registries/mcp_official}.rs` (#3039, #4272) | ✅ | Skills `?tab=mcp` renders `McpServersTab` (not Coming Soon); auto-connect on install (best-effort); typed `mcpSetupApi` wrapper; curated "perfect server" catalog (declared website + named credential) with official-vendor badge + official-first order; namespace-stripped relevance search + server-side Stdio/Hosted transport filter; clickable Website/Repo links; wired connection health toolbar (Retry all / Disconnect all) (#4272) |
|
||||||
| 11.1.13 | MCP HTTP-remote auth (token / Bearer / OAuth) + redirect resolution | RU/VU | `src/openhuman/mcp_registry/connections.rs` (`build_http_auth*`, `resolve_final_url`), `src/openhuman/mcp_registry/oauth.rs` (PKCE/token/bundle/callback port), `app/src/components/channels/mcp/ConnectAuthModal.test.tsx` (#3495) | ✅ | Bearer/raw scheme + custom headers; redirect-final-URL resolved before auth; OAuth dynamic client registration + PKCE + refresh; tokens MERGED into stored env; credentials stored encrypted locally, never sent to backend |
|
| 11.1.13 | MCP HTTP-remote auth (token / Bearer / OAuth) + redirect resolution | RU/VU | `src/openhuman/mcp_registry/connections.rs` (`build_http_auth*`, `resolve_final_url`), `src/openhuman/mcp_registry/oauth.rs` (PKCE/token/bundle/callback port), `app/src/components/channels/mcp/ConnectAuthModal.test.tsx` (#3495) | ✅ | Bearer/raw scheme + custom headers; redirect-final-URL resolved before auth; OAuth dynamic client registration + PKCE + refresh; tokens MERGED into stored env; credentials stored encrypted locally, never sent to backend |
|
||||||
| 11.1.14 | MCP "Help & configure" assistant | VU/RU | `app/src/components/channels/mcp/ConfigAssistantPanel.test.tsx`, `app/src/components/channels/mcp/ConfigHelpModal.test.tsx`, `src/openhuman/mcp_registry/ops.rs` (`invoke_config_assist_agent`) (#3495) | ✅ | Fixed server-specific prompt runs an agentic turn scoped to web_search_tool/web_fetch/curl only; markdown-rendered reply; per-MCP chat persisted while on the detail page |
|
| 11.1.14 | MCP "Help & configure" assistant | VU/RU | `app/src/components/channels/mcp/ConfigAssistantPanel.test.tsx`, `app/src/components/channels/mcp/ConfigHelpModal.test.tsx`, `src/openhuman/mcp_registry/ops.rs` (`invoke_config_assist_agent`) (#3495) | ✅ | Server-specific prompt offered as a one-click "Get step-by-step setup help" action (on-demand, no longer auto-run on open — #4272), running an agentic turn scoped to web_search_tool/web_fetch/curl only; markdown-rendered reply; per-MCP chat persisted while on the detail page |
|
||||||
| 11.1.15 | Agent uses connected MCP servers in chat | RU | `src/openhuman/agent_registry/agents/loader.rs` (`orchestrator_subagents_include_mcp_agent`, `mcp_agent_drives_connected_servers_without_install_or_shell`, `planner_has_readonly_mcp_discovery_not_execute`), `src/openhuman/agent_registry/agents/orchestrator/prompt.rs` (`connected_mcp_block_*`), `src/openhuman/agent/harness/session/turn_tests.rs` (`mcp_announcement_fires_once_for_new_server`), `src/openhuman/mcp_registry/{tools,connections}.rs` (#3495) | ✅ | `use_mcp_server` delegate → `mcp_agent` worker (discover→list→call); `mcp_registry_list_tools` read-only discovery; orchestrator `## Connected MCP Servers` prompt block + mid-session connect announcement on the user turn; planner read-only MCP discovery (no `tool_call`) |
|
| 11.1.15 | Agent uses connected MCP servers in chat | RU | `src/openhuman/agent_registry/agents/loader.rs` (`orchestrator_subagents_include_mcp_agent`, `mcp_agent_drives_connected_servers_without_install_or_shell`, `planner_has_readonly_mcp_discovery_not_execute`), `src/openhuman/agent_registry/agents/orchestrator/prompt.rs` (`connected_mcp_block_*`), `src/openhuman/agent/harness/session/turn_tests.rs` (`mcp_announcement_fires_once_for_new_server`), `src/openhuman/mcp_registry/{tools,connections}.rs` (#3495) | ✅ | `use_mcp_server` delegate → `mcp_agent` worker (discover→list→call); `mcp_registry_list_tools` read-only discovery; orchestrator `## Connected MCP Servers` prompt block + mid-session connect announcement on the user turn; planner read-only MCP discovery (no `tool_call`) |
|
||||||
|
|
||||||
<!-- 11.1.9 Vault Markdown Writes — removed: Knowledge Vaults dropped (vault domain + VaultPanel deleted). -->
|
<!-- 11.1.9 Vault Markdown Writes — removed: Knowledge Vaults dropped (vault domain + VaultPanel deleted). -->
|
||||||
|
|
||||||
@@ -559,11 +559,11 @@ End-to-end coverage of the agent harness via the web-chat RPC surface against an
|
|||||||
|
|
||||||
### 13.6 Keyboard Shortcuts & Command Surface
|
### 13.6 Keyboard Shortcuts & Command Surface
|
||||||
|
|
||||||
| ID | Feature | Layer | Test path(s) | Status | Notes |
|
| ID | Feature | Layer | Test path(s) | Status | Notes |
|
||||||
| ------ | ---------------------------------------- | ----- | ----------------------------------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------ | -------------------------------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 13.6.1 | Command Palette (⌘K / ⌘P) | VU+WD | `app/src/lib/commands/__tests__/globalActions.test.tsx`, `app/src/components/commands/__tests__/CommandProvider.test.tsx`, `app/test/e2e/specs/command-palette.spec.ts` | ✅ | Opens via ⌘K, runs an action, lists seed nav actions, Esc closes |
|
| 13.6.1 | Command Palette (⌘K / ⌘P) | VU+WD | `app/src/lib/commands/__tests__/globalActions.test.tsx`, `app/src/components/commands/__tests__/CommandProvider.test.tsx`, `app/test/e2e/specs/command-palette.spec.ts` | ✅ | Opens via ⌘K, runs an action, lists seed nav actions, Esc closes |
|
||||||
| 13.6.2 | Keyboard Shortcuts help directory (? / ⌘/) | VU+WD | `app/src/components/shortcuts/__tests__/shortcutsView.test.tsx`, `app/src/components/commands/__tests__/CommandProvider.test.tsx`, `app/test/e2e/specs/command-palette.spec.ts` | ✅ | Registry-driven grouped list; opens via `?` and ⌘/ (mutually exclusive with palette); also reachable from the sidebar shortcut icon + Settings → Keyboard Shortcuts |
|
| 13.6.2 | Keyboard Shortcuts help directory (? / ⌘/) | VU+WD | `app/src/components/shortcuts/__tests__/shortcutsView.test.tsx`, `app/src/components/commands/__tests__/CommandProvider.test.tsx`, `app/test/e2e/specs/command-palette.spec.ts` | ✅ | Registry-driven grouped list; opens via `?` and ⌘/ (mutually exclusive with palette); also reachable from the sidebar shortcut icon + Settings → Keyboard Shortcuts |
|
||||||
| 13.6.3 | Global shortcut map (nav / chat / view / profiles) | VU | `app/src/lib/commands/__tests__/globalActions.test.tsx` | ✅ | Control-based nav (`ctrl`/`mod` per-OS), New Chat / Toggle Sidebar handlers, alias keys, and wired-but-hidden profile switches |
|
| 13.6.3 | Global shortcut map (nav / chat / view / profiles) | VU | `app/src/lib/commands/__tests__/globalActions.test.tsx` | ✅ | Control-based nav (`ctrl`/`mod` per-OS), New Chat / Toggle Sidebar handlers, alias keys, and wired-but-hidden profile switches |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,35 @@ pub fn tag_official(servers: &mut [SmitheryServerSummary]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a catalog row fully specifies how to connect *from its metadata
|
||||||
|
/// alone* — no probe, no guessing. A "perfect" server declares both a
|
||||||
|
/// `website_url` (a trust/quality signal and the user's get-key destination)
|
||||||
|
/// and a named static credential (`auth_kind == "api_key"`).
|
||||||
|
pub(super) fn is_perfect_server(s: &SmitheryServerSummary) -> bool {
|
||||||
|
s.website_url
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|u| !u.trim().is_empty())
|
||||||
|
&& s.auth_kind.as_deref() == Some("api_key")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strict "perfect server" catalog filter. Keeps only [`is_perfect_server`]
|
||||||
|
/// rows, dropping OAuth-only, open, and under-declared servers (and every
|
||||||
|
/// Smithery summary, which carries neither website nor declared auth). This is
|
||||||
|
/// a deliberate quality-over-quantity trade-off (#4272): the user only ever
|
||||||
|
/// sees servers that can be installed and connected with confidence. Returns
|
||||||
|
/// the number of rows dropped so callers can log the trim. Mutates in place.
|
||||||
|
pub fn retain_perfect_servers(servers: &mut Vec<SmitheryServerSummary>) -> usize {
|
||||||
|
let before = servers.len();
|
||||||
|
servers.retain(is_perfect_server);
|
||||||
|
before - servers.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Float the canonical first-party (`official`) servers to the top while
|
||||||
|
/// preserving the registry's relevance order for everything else (stable sort).
|
||||||
|
pub fn float_official_first(servers: &mut [SmitheryServerSummary]) {
|
||||||
|
servers.sort_by_key(|s| !s.official);
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -59,10 +88,21 @@ mod tests {
|
|||||||
is_deployed: true,
|
is_deployed: true,
|
||||||
source: "mcp_official".to_string(),
|
source: "mcp_official".to_string(),
|
||||||
official: false,
|
official: false,
|
||||||
|
website_url: None,
|
||||||
|
auth_kind: None,
|
||||||
extra: Default::default(),
|
extra: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A "perfect" server: declared website + api_key auth.
|
||||||
|
fn perfect(qualified_name: &str) -> SmitheryServerSummary {
|
||||||
|
SmitheryServerSummary {
|
||||||
|
website_url: Some("https://vendor.example".to_string()),
|
||||||
|
auth_kind: Some("api_key".to_string()),
|
||||||
|
..server(qualified_name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tags_only_exact_canonical_servers() {
|
fn tags_only_exact_canonical_servers() {
|
||||||
let mut servers = vec![
|
let mut servers = vec![
|
||||||
@@ -87,4 +127,48 @@ mod tests {
|
|||||||
"a name merely containing 'stripe' must not be marked official"
|
"a name merely containing 'stripe' must not be marked official"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retain_perfect_keeps_only_website_plus_api_key() {
|
||||||
|
let mut servers = vec![
|
||||||
|
perfect("com.acme/mcp"), // website + api_key → kept
|
||||||
|
SmitheryServerSummary {
|
||||||
|
auth_kind: None,
|
||||||
|
..perfect("oauth/srv")
|
||||||
|
}, // no key → dropped
|
||||||
|
SmitheryServerSummary {
|
||||||
|
website_url: None,
|
||||||
|
..perfect("nosite/srv")
|
||||||
|
}, // no site → dropped
|
||||||
|
SmitheryServerSummary {
|
||||||
|
website_url: Some(" ".to_string()),
|
||||||
|
..perfect("blank/srv")
|
||||||
|
}, // blank site → dropped
|
||||||
|
server("smi/community"), // neither → dropped
|
||||||
|
];
|
||||||
|
|
||||||
|
let dropped = retain_perfect_servers(&mut servers);
|
||||||
|
|
||||||
|
let slugs: Vec<_> = servers.iter().map(|s| s.qualified_name.as_str()).collect();
|
||||||
|
assert_eq!(slugs, vec!["com.acme/mcp"]);
|
||||||
|
assert_eq!(dropped, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn float_official_first_is_stable() {
|
||||||
|
let mut servers = vec![
|
||||||
|
perfect("a/one"),
|
||||||
|
SmitheryServerSummary {
|
||||||
|
official: true,
|
||||||
|
..perfect("b/official")
|
||||||
|
},
|
||||||
|
perfect("c/two"),
|
||||||
|
];
|
||||||
|
|
||||||
|
float_official_first(&mut servers);
|
||||||
|
|
||||||
|
let slugs: Vec<_> = servers.iter().map(|s| s.qualified_name.as_str()).collect();
|
||||||
|
// Official floats to the top; the rest keep their relative order.
|
||||||
|
assert_eq!(slugs, vec!["b/official", "a/one", "c/two"]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ use super::types::{CommandKind, ConnStatus, InstalledServer};
|
|||||||
pub async fn mcp_clients_registry_search(
|
pub async fn mcp_clients_registry_search(
|
||||||
config: &Config,
|
config: &Config,
|
||||||
query: Option<String>,
|
query: Option<String>,
|
||||||
|
transport: Option<String>,
|
||||||
page: Option<u32>,
|
page: Option<u32>,
|
||||||
page_size: Option<u32>,
|
page_size: Option<u32>,
|
||||||
) -> Result<RpcOutcome<Value>, String> {
|
) -> Result<RpcOutcome<Value>, String> {
|
||||||
@@ -30,16 +31,22 @@ pub async fn mcp_clients_registry_search(
|
|||||||
let page_size = page_size.unwrap_or(20);
|
let page_size = page_size.unwrap_or(20);
|
||||||
|
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
"[mcp-client] registry_search query={:?} page={} page_size={}",
|
"[mcp-client] registry_search query={:?} transport={:?} page={} page_size={}",
|
||||||
query,
|
query,
|
||||||
|
transport,
|
||||||
page,
|
page,
|
||||||
page_size
|
page_size
|
||||||
);
|
);
|
||||||
|
|
||||||
let (servers, total_pages) =
|
let (servers, total_pages) = registry::registry_search(
|
||||||
registry::registry_search(config, query.as_deref(), page, page_size)
|
config,
|
||||||
.await
|
query.as_deref(),
|
||||||
.map_err(|e| e.to_string())?;
|
transport.as_deref(),
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
Ok(RpcOutcome::new(
|
Ok(RpcOutcome::new(
|
||||||
json!({ "servers": servers, "page": page, "total_pages": total_pages }),
|
json!({ "servers": servers, "page": page, "total_pages": total_pages }),
|
||||||
|
|||||||
@@ -563,9 +563,40 @@ struct OfficialServer {
|
|||||||
/// Installable subprocess packages (npm, pip, brew, …).
|
/// Installable subprocess packages (npm, pip, brew, …).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
packages: Vec<OfficialPackage>,
|
packages: Vec<OfficialPackage>,
|
||||||
|
/// Vendor/site URL, when declared. Trust/quality signal required by the
|
||||||
|
/// strict "perfect server" catalog filter and rendered as a clickable link.
|
||||||
|
#[serde(default, rename = "websiteUrl")]
|
||||||
|
website_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OfficialServer {
|
impl OfficialServer {
|
||||||
|
/// Non-empty declared `websiteUrl`, if any.
|
||||||
|
fn website(&self) -> Option<String> {
|
||||||
|
self.website_url
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the server *declares* a named static secret credential in its
|
||||||
|
/// schema — a secret/`Authorization` header or a secret env var. This is the
|
||||||
|
/// metadata signal for "static API key / token", with no probe and no
|
||||||
|
/// guessing; it drives `auth_kind == "api_key"`.
|
||||||
|
fn declares_secret_credential(&self) -> bool {
|
||||||
|
let header = self.remotes.iter().any(|r| {
|
||||||
|
r.headers
|
||||||
|
.iter()
|
||||||
|
.any(|h| h.is_secret == Some(true) || h.name.eq_ignore_ascii_case("authorization"))
|
||||||
|
});
|
||||||
|
let env = self.packages.iter().any(|p| {
|
||||||
|
p.environment_variables
|
||||||
|
.iter()
|
||||||
|
.any(|e| e.is_secret == Some(true))
|
||||||
|
});
|
||||||
|
header || env
|
||||||
|
}
|
||||||
|
|
||||||
fn display_name(&self) -> String {
|
fn display_name(&self) -> String {
|
||||||
if let Some(title) = self.title.as_deref().filter(|s| !s.trim().is_empty()) {
|
if let Some(title) = self.title.as_deref().filter(|s| !s.trim().is_empty()) {
|
||||||
return title.to_string();
|
return title.to_string();
|
||||||
@@ -584,6 +615,12 @@ impl OfficialServer {
|
|||||||
|
|
||||||
fn into_summary(self) -> SmitheryServerSummary {
|
fn into_summary(self) -> SmitheryServerSummary {
|
||||||
let display = self.display_name();
|
let display = self.display_name();
|
||||||
|
let website_url = self.website();
|
||||||
|
let auth_kind = if self.declares_secret_credential() {
|
||||||
|
Some("api_key".to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
SmitheryServerSummary {
|
SmitheryServerSummary {
|
||||||
qualified_name: self.name.clone(),
|
qualified_name: self.name.clone(),
|
||||||
display_name: display,
|
display_name: display,
|
||||||
@@ -593,6 +630,8 @@ impl OfficialServer {
|
|||||||
is_deployed: !self.remotes.is_empty(),
|
is_deployed: !self.remotes.is_empty(),
|
||||||
source: SOURCE_MCP_OFFICIAL.to_string(),
|
source: SOURCE_MCP_OFFICIAL.to_string(),
|
||||||
official: false, // tagged later by the registry dispatcher
|
official: false, // tagged later by the registry dispatcher
|
||||||
|
website_url,
|
||||||
|
auth_kind,
|
||||||
extra: std::collections::HashMap::new(),
|
extra: std::collections::HashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -819,6 +858,63 @@ mod tests {
|
|||||||
assert_eq!(sum.source, SOURCE_MCP_OFFICIAL);
|
assert_eq!(sum.source, SOURCE_MCP_OFFICIAL);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn into_summary_stamps_website_and_api_key_from_declared_secret_header() {
|
||||||
|
// A server declaring a secret Authorization header + a websiteUrl is a
|
||||||
|
// "perfect" server: auth_kind=api_key (from metadata, no probe) + site.
|
||||||
|
let s: OfficialServer = serde_json::from_value(json!({
|
||||||
|
"name": "com.acme/mcp",
|
||||||
|
"websiteUrl": "https://www.acme.ai",
|
||||||
|
"remotes": [{
|
||||||
|
"url": "https://api.acme.ai/mcp",
|
||||||
|
"headers": [{ "name": "Authorization", "isSecret": true }],
|
||||||
|
}],
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
let sum = s.into_summary();
|
||||||
|
assert_eq!(sum.website_url.as_deref(), Some("https://www.acme.ai"));
|
||||||
|
assert_eq!(sum.auth_kind.as_deref(), Some("api_key"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn into_summary_secret_env_var_also_counts_as_api_key() {
|
||||||
|
let s: OfficialServer = serde_json::from_value(json!({
|
||||||
|
"name": "io.github.x/y",
|
||||||
|
"websiteUrl": "https://site.example",
|
||||||
|
"packages": [{
|
||||||
|
"registryType": "npm",
|
||||||
|
"environmentVariables": [{ "name": "API_KEY", "isSecret": true }],
|
||||||
|
}],
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(s.into_summary().auth_kind.as_deref(), Some("api_key"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn into_summary_no_auth_kind_when_no_secret_declared() {
|
||||||
|
// OAuth/open servers declare no key in metadata → auth_kind=None. The
|
||||||
|
// strict catalog filter drops these even though they carry a website.
|
||||||
|
let s: OfficialServer = serde_json::from_value(json!({
|
||||||
|
"name": "open/server",
|
||||||
|
"websiteUrl": "https://x.example",
|
||||||
|
"remotes": [{ "url": "https://open.example.com/mcp" }],
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
let sum = s.into_summary();
|
||||||
|
assert_eq!(sum.website_url.as_deref(), Some("https://x.example"));
|
||||||
|
assert_eq!(sum.auth_kind, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn into_summary_trims_blank_website_to_none() {
|
||||||
|
let s: OfficialServer = serde_json::from_value(json!({
|
||||||
|
"name": "blank/site",
|
||||||
|
"websiteUrl": " ",
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(s.into_summary().website_url, None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn list_response_tolerates_missing_metadata() {
|
fn list_response_tolerates_missing_metadata() {
|
||||||
let raw = json!({ "servers": [] });
|
let raw = json!({ "servers": [] });
|
||||||
|
|||||||
@@ -183,6 +183,15 @@ fn tag_source(mut servers: Vec<SmitheryServerSummary>) -> Vec<SmitheryServerSumm
|
|||||||
if s.source.is_empty() {
|
if s.source.is_empty() {
|
||||||
s.source = SOURCE_SMITHERY.to_string();
|
s.source = SOURCE_SMITHERY.to_string();
|
||||||
}
|
}
|
||||||
|
// `website_url` / `auth_kind` are curation-only trust signals derived by
|
||||||
|
// the official adapter — never accept them from the Smithery wire (a
|
||||||
|
// payload emitting either key must not survive the strict "perfect
|
||||||
|
// server" filter on spoofed metadata). Scrub the fields AND the flatten
|
||||||
|
// bucket so they can't re-serialize.
|
||||||
|
s.website_url = None;
|
||||||
|
s.auth_kind = None;
|
||||||
|
s.extra.remove("website_url");
|
||||||
|
s.extra.remove("auth_kind");
|
||||||
}
|
}
|
||||||
servers
|
servers
|
||||||
}
|
}
|
||||||
@@ -225,4 +234,35 @@ mod tests {
|
|||||||
let encoded = urlencoding_encode("hello world");
|
let encoded = urlencoding_encode("hello world");
|
||||||
assert_eq!(encoded, "hello%20world");
|
assert_eq!(encoded, "hello%20world");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tag_source_scrubs_curation_only_trust_signals_from_the_wire() {
|
||||||
|
// A (hostile/buggy) Smithery payload emitting website_url/auth_kind —
|
||||||
|
// both on the field and via the flatten bucket — must not survive: these
|
||||||
|
// are adapter-derived signals the strict filter trusts.
|
||||||
|
let mut server = SmitheryServerSummary {
|
||||||
|
qualified_name: "smi/evil".to_string(),
|
||||||
|
display_name: "Evil".to_string(),
|
||||||
|
description: None,
|
||||||
|
icon_url: None,
|
||||||
|
use_count: 0,
|
||||||
|
is_deployed: false,
|
||||||
|
source: String::new(),
|
||||||
|
official: false,
|
||||||
|
website_url: Some("https://spoofed.example".to_string()),
|
||||||
|
auth_kind: Some("api_key".to_string()),
|
||||||
|
extra: Default::default(),
|
||||||
|
};
|
||||||
|
server
|
||||||
|
.extra
|
||||||
|
.insert("auth_kind".to_string(), serde_json::json!("api_key"));
|
||||||
|
|
||||||
|
let tagged = tag_source(vec![server]);
|
||||||
|
|
||||||
|
assert_eq!(tagged[0].source, SOURCE_SMITHERY);
|
||||||
|
assert_eq!(tagged[0].website_url, None);
|
||||||
|
assert_eq!(tagged[0].auth_kind, None);
|
||||||
|
assert!(!tagged[0].extra.contains_key("website_url"));
|
||||||
|
assert!(!tagged[0].extra.contains_key("auth_kind"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
//! Multi-registry dispatch entry point.
|
//! Multi-registry search + detail dispatch.
|
||||||
//!
|
//!
|
||||||
//! `registry_search` fans out to every registry in
|
//! `registry_search` fans out to every enabled registry in parallel and
|
||||||
//! [`super::registries::enabled_registries`], runs them in parallel, and
|
//! over-fetches to offset the strict "perfect server" filter, then merges +
|
||||||
//! returns merged results (failed registries are logged and skipped so one
|
//! dedups, badges the canonical vendor server, drops non-perfect rows, applies
|
||||||
//! flaky upstream doesn't blank the UI). The official modelcontextprotocol.io
|
//! the transport filter, and floats official servers to the top. (A previous
|
||||||
//! registry is listed first so its results take priority.
|
//! attempt to fetch + cache the *entire* catalog up front was reverted: the full
|
||||||
|
//! cursor walk exceeded the 30 s RPC budget and blanked the page.)
|
||||||
//!
|
//!
|
||||||
//! `registry_get` routes by [`super::types::SmitheryServerDetail::source`].
|
//! `registry_get` routes by [`super::types::SmitheryServerDetail::source`].
|
||||||
//! The caller can pass an explicit source prefix using
|
//! The caller can pass an explicit source prefix using
|
||||||
@@ -23,21 +24,49 @@ use super::types::{SmitheryServerDetail, SmitheryServerSummary};
|
|||||||
|
|
||||||
const SOURCE_SEPARATOR: &str = "::";
|
const SOURCE_SEPARATOR: &str = "::";
|
||||||
|
|
||||||
/// Search every enabled registry in parallel; merge results. `total_pages`
|
/// Over-fetch factor for the strict catalog. The "perfect server" filter drops
|
||||||
/// is the max page count reported across registries (best-effort upper
|
/// most upstream rows, so fetching exactly `page_size` would return a
|
||||||
/// bound).
|
/// near-empty page. We pull a multiple of the requested size so each returned
|
||||||
|
/// page lands close to `page_size` usable rows.
|
||||||
|
const STRICT_OVERFETCH_FACTOR: u32 = 5;
|
||||||
|
/// Cap on the per-page upstream fetch (registry list endpoints cap `limit`
|
||||||
|
/// around here anyway).
|
||||||
|
const MAX_FETCH_PAGE_SIZE: u32 = 100;
|
||||||
|
|
||||||
|
/// Raw rows to request upstream for a requested `page_size` of strict results.
|
||||||
|
fn strict_fetch_size(page_size: u32) -> u32 {
|
||||||
|
page_size
|
||||||
|
.saturating_mul(STRICT_OVERFETCH_FACTOR)
|
||||||
|
.clamp(1, MAX_FETCH_PAGE_SIZE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-registry search results tagged with their source id, for [`merge_registry_results`].
|
||||||
|
type LabelledResults = Vec<(&'static str, Result<(Vec<SmitheryServerSummary>, u32)>)>;
|
||||||
|
|
||||||
|
/// Search every enabled registry in parallel, over-fetch, merge, then curate.
|
||||||
|
/// `query` is passed to the registries' own search; `transport` (`"stdio"` |
|
||||||
|
/// `"hosted"` | `"all"`/`None`) filters the merged rows by how they run.
|
||||||
pub async fn registry_search(
|
pub async fn registry_search(
|
||||||
config: &Config,
|
config: &Config,
|
||||||
query: Option<&str>,
|
query: Option<&str>,
|
||||||
|
transport: Option<&str>,
|
||||||
page: u32,
|
page: u32,
|
||||||
page_size: u32,
|
page_size: u32,
|
||||||
) -> Result<(Vec<SmitheryServerSummary>, u32)> {
|
) -> Result<(Vec<SmitheryServerSummary>, u32)> {
|
||||||
let registries = enabled_registries(config);
|
let registries = enabled_registries(config);
|
||||||
let queries = registries
|
let fetch_size = strict_fetch_size(page_size);
|
||||||
.iter()
|
let results = join_all(
|
||||||
.map(|r| r.search(config, query, page, page_size));
|
registries
|
||||||
let results = join_all(queries).await;
|
.iter()
|
||||||
|
.map(|r| r.search(config, query, page, fetch_size)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// A total outage (every registry errored) is distinct from "no perfect
|
||||||
|
// servers": return an error so the UI shows its registry error state instead
|
||||||
|
// of an empty catalog. `merge_registry_results` logs+skips the individual
|
||||||
|
// failures.
|
||||||
|
let any_ok = results.iter().any(Result::is_ok);
|
||||||
let labelled = results
|
let labelled = results
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
@@ -46,45 +75,112 @@ pub async fn registry_search(
|
|||||||
|
|
||||||
let (mut merged, mut total_pages) = merge_registry_results(labelled);
|
let (mut merged, mut total_pages) = merge_registry_results(labelled);
|
||||||
|
|
||||||
// Keep the full deduped catalog browsable — no one-per-service collapse
|
if !any_ok && !registries.is_empty() {
|
||||||
// (it hides genuinely different community servers and barely trims noise).
|
anyhow::bail!("all MCP registries failed to respond");
|
||||||
// Just badge the canonical first-party server for each known service so the
|
}
|
||||||
// official one is easy to spot without throwing any alternatives away.
|
|
||||||
|
// Badge the canonical first-party server, drop non-perfect rows, refine
|
||||||
|
// search relevance, filter by transport, float official to the top.
|
||||||
super::curation::tag_official(&mut merged);
|
super::curation::tag_official(&mut merged);
|
||||||
|
super::curation::retain_perfect_servers(&mut merged);
|
||||||
|
if let Some(q) = query.map(str::trim).filter(|q| !q.is_empty()) {
|
||||||
|
merged = refine_by_relevance(merged, q);
|
||||||
|
}
|
||||||
|
apply_transport(&mut merged, transport);
|
||||||
|
super::curation::float_official_first(&mut merged);
|
||||||
|
|
||||||
if total_pages == 0 {
|
if total_pages == 0 {
|
||||||
total_pages = page.max(1);
|
total_pages = page.max(1);
|
||||||
}
|
}
|
||||||
|
tracing::debug!(
|
||||||
|
"[mcp-registry] search page={page} returned={} total_pages={total_pages} has_query={} transport={:?}",
|
||||||
|
merged.len(),
|
||||||
|
query.map(|q| !q.trim().is_empty()).unwrap_or(false),
|
||||||
|
transport
|
||||||
|
);
|
||||||
Ok((merged, total_pages))
|
Ok((merged, total_pages))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drop rows that don't match the requested transport (`"stdio"` | `"hosted"`).
|
||||||
|
/// `None`/`"all"` keeps everything.
|
||||||
|
fn apply_transport(servers: &mut Vec<SmitheryServerSummary>, transport: Option<&str>) {
|
||||||
|
if let Some(tp) = transport
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|t| !t.is_empty() && *t != "all")
|
||||||
|
{
|
||||||
|
servers.retain(|s| match tp {
|
||||||
|
"hosted" => s.is_deployed,
|
||||||
|
"stdio" => !s.is_deployed,
|
||||||
|
_ => true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip a code-host namespace root so a query matches the meaningful part of a
|
||||||
|
/// slug, not the shared host. `io.github.06ketan/medium-ops` → `06ketan/medium-ops`.
|
||||||
|
fn searchable_slug(qualified_name: &str) -> &str {
|
||||||
|
const CODE_HOST_PREFIXES: &[&str] = &["io.github.", "io.gitlab."];
|
||||||
|
for prefix in CODE_HOST_PREFIXES {
|
||||||
|
if let Some(rest) = qualified_name.strip_prefix(prefix) {
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
qualified_name
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep only rows matching every query token across the display name, the
|
||||||
|
/// namespace-stripped slug, and the description — so searching "github" doesn't
|
||||||
|
/// match every `io.github.<user>/*` community server just by its namespace.
|
||||||
|
/// Safe-by-default: if the refinement would empty the page, the unrefined rows
|
||||||
|
/// are returned (a looser list beats a blank one).
|
||||||
|
fn refine_by_relevance(
|
||||||
|
servers: Vec<SmitheryServerSummary>,
|
||||||
|
query: &str,
|
||||||
|
) -> Vec<SmitheryServerSummary> {
|
||||||
|
let tokens: Vec<String> = query
|
||||||
|
.to_lowercase()
|
||||||
|
.split_whitespace()
|
||||||
|
.map(String::from)
|
||||||
|
.collect();
|
||||||
|
if tokens.is_empty() {
|
||||||
|
return servers;
|
||||||
|
}
|
||||||
|
let refined: Vec<SmitheryServerSummary> = servers
|
||||||
|
.iter()
|
||||||
|
.filter(|s| {
|
||||||
|
let haystack = format!(
|
||||||
|
"{} {} {}",
|
||||||
|
s.display_name.to_lowercase(),
|
||||||
|
searchable_slug(&s.qualified_name).to_lowercase(),
|
||||||
|
s.description.as_deref().unwrap_or("").to_lowercase()
|
||||||
|
);
|
||||||
|
tokens.iter().all(|t| haystack.contains(t.as_str()))
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
if refined.is_empty() {
|
||||||
|
servers
|
||||||
|
} else {
|
||||||
|
refined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Merge per-registry search results into one list, dropping exact
|
/// Merge per-registry search results into one list, dropping exact
|
||||||
/// `qualified_name` duplicates. Registries are passed in priority order
|
/// `qualified_name` duplicates. Registries are passed in priority order
|
||||||
/// (official before Smithery), and the first occurrence of a slug wins — so a
|
/// (official before Smithery), and the first occurrence of a slug wins.
|
||||||
/// package listed on both registries collapses to the higher-priority copy and
|
/// `total_pages` is the max reported across registries that succeeded; failed
|
||||||
/// the UI never shows the same slug twice. `total_pages` is the max reported
|
/// registries are logged and skipped so one flaky upstream can't blank results.
|
||||||
/// across the registries that succeeded. Failed registries are logged and
|
fn merge_registry_results(results: LabelledResults) -> (Vec<SmitheryServerSummary>, u32) {
|
||||||
/// skipped so one flaky upstream can't blank the catalog.
|
|
||||||
fn merge_registry_results(
|
|
||||||
results: Vec<(&'static str, Result<(Vec<SmitheryServerSummary>, u32)>)>,
|
|
||||||
) -> (Vec<SmitheryServerSummary>, u32) {
|
|
||||||
let mut merged: Vec<SmitheryServerSummary> = Vec::new();
|
let mut merged: Vec<SmitheryServerSummary> = Vec::new();
|
||||||
let mut seen: HashSet<String> = HashSet::new();
|
let mut seen: HashSet<String> = HashSet::new();
|
||||||
let mut total_pages: u32 = 0;
|
let mut total_pages: u32 = 0;
|
||||||
let mut dropped: usize = 0;
|
|
||||||
|
|
||||||
for (source, res) in results {
|
for (source, res) in results {
|
||||||
match res {
|
match res {
|
||||||
Ok((servers, pages)) => {
|
Ok((servers, pages)) => {
|
||||||
tracing::debug!(
|
|
||||||
"[mcp-registry] {source} search ok servers={} pages={pages}",
|
|
||||||
servers.len()
|
|
||||||
);
|
|
||||||
for server in servers {
|
for server in servers {
|
||||||
if seen.insert(server.qualified_name.clone()) {
|
if seen.insert(server.qualified_name.clone()) {
|
||||||
merged.push(server);
|
merged.push(server);
|
||||||
} else {
|
|
||||||
dropped += 1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
total_pages = total_pages.max(pages);
|
total_pages = total_pages.max(pages);
|
||||||
@@ -94,10 +190,6 @@ fn merge_registry_results(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if dropped > 0 {
|
|
||||||
tracing::debug!("[mcp-registry] dropped {dropped} cross-registry duplicate slug(s)");
|
|
||||||
}
|
|
||||||
(merged, total_pages)
|
(merged, total_pages)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,13 +237,22 @@ mod tests {
|
|||||||
is_deployed: false,
|
is_deployed: false,
|
||||||
source: source.to_string(),
|
source: source.to_string(),
|
||||||
official: false,
|
official: false,
|
||||||
|
website_url: None,
|
||||||
|
auth_kind: None,
|
||||||
extra: Default::default(),
|
extra: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strict_fetch_size_overfetches_and_caps() {
|
||||||
|
assert_eq!(strict_fetch_size(10), 50);
|
||||||
|
assert_eq!(strict_fetch_size(30), MAX_FETCH_PAGE_SIZE);
|
||||||
|
assert_eq!(strict_fetch_size(1_000_000), MAX_FETCH_PAGE_SIZE);
|
||||||
|
assert_eq!(strict_fetch_size(0), 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn merge_keeps_higher_priority_duplicate_and_drops_the_rest() {
|
fn merge_keeps_higher_priority_duplicate_and_drops_the_rest() {
|
||||||
// `dup/server` is listed on both registries; official is passed first.
|
|
||||||
let results = vec![
|
let results = vec![
|
||||||
(
|
(
|
||||||
"mcp_official",
|
"mcp_official",
|
||||||
@@ -179,13 +280,14 @@ mod tests {
|
|||||||
|
|
||||||
let slugs: Vec<_> = merged.iter().map(|s| s.qualified_name.as_str()).collect();
|
let slugs: Vec<_> = merged.iter().map(|s| s.qualified_name.as_str()).collect();
|
||||||
assert_eq!(slugs, vec!["dup/server", "off/only", "smi/only"]);
|
assert_eq!(slugs, vec!["dup/server", "off/only", "smi/only"]);
|
||||||
// The surviving duplicate is the official copy (first occurrence wins).
|
assert_eq!(
|
||||||
let dup = merged
|
merged
|
||||||
.iter()
|
.iter()
|
||||||
.find(|s| s.qualified_name == "dup/server")
|
.find(|s| s.qualified_name == "dup/server")
|
||||||
.unwrap();
|
.unwrap()
|
||||||
assert_eq!(dup.source, "mcp_official");
|
.source,
|
||||||
// total_pages is the max across registries.
|
"mcp_official"
|
||||||
|
);
|
||||||
assert_eq!(total_pages, 5);
|
assert_eq!(total_pages, 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,11 +297,52 @@ mod tests {
|
|||||||
("mcp_official", Err(anyhow::anyhow!("upstream 500"))),
|
("mcp_official", Err(anyhow::anyhow!("upstream 500"))),
|
||||||
("smithery", Ok((vec![summary("smi/only", "smithery")], 2))),
|
("smithery", Ok((vec![summary("smi/only", "smithery")], 2))),
|
||||||
];
|
];
|
||||||
|
|
||||||
let (merged, total_pages) = merge_registry_results(results);
|
let (merged, total_pages) = merge_registry_results(results);
|
||||||
|
|
||||||
assert_eq!(merged.len(), 1);
|
assert_eq!(merged.len(), 1);
|
||||||
assert_eq!(merged[0].qualified_name, "smi/only");
|
assert_eq!(merged[0].qualified_name, "smi/only");
|
||||||
assert_eq!(total_pages, 2);
|
assert_eq!(total_pages, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_transport_filters_by_run_kind() {
|
||||||
|
let mut servers = vec![
|
||||||
|
SmitheryServerSummary {
|
||||||
|
is_deployed: true,
|
||||||
|
..summary("a/hosted", "mcp_official")
|
||||||
|
},
|
||||||
|
summary("b/stdio", "mcp_official"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut hosted = servers.clone();
|
||||||
|
apply_transport(&mut hosted, Some("hosted"));
|
||||||
|
assert_eq!(hosted.len(), 1);
|
||||||
|
assert_eq!(hosted[0].qualified_name, "a/hosted");
|
||||||
|
|
||||||
|
apply_transport(&mut servers, Some("stdio"));
|
||||||
|
assert_eq!(servers.len(), 1);
|
||||||
|
assert_eq!(servers[0].qualified_name, "b/stdio");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn refine_excludes_namespace_only_matches_but_never_empties() {
|
||||||
|
let real = SmitheryServerSummary {
|
||||||
|
display_name: "GitHub".to_string(),
|
||||||
|
description: Some("Official GitHub MCP server".to_string()),
|
||||||
|
..summary("io.github.github/github-mcp-server", "mcp_official")
|
||||||
|
};
|
||||||
|
let community = SmitheryServerSummary {
|
||||||
|
display_name: "medium-ops".to_string(),
|
||||||
|
description: Some("Medium CLI. No API keys.".to_string()),
|
||||||
|
..summary("io.github.06ketan/medium-ops", "mcp_official")
|
||||||
|
};
|
||||||
|
|
||||||
|
// "github" keeps the real one, drops the namespace-only community row.
|
||||||
|
let hits = refine_by_relevance(vec![real.clone(), community.clone()], "github");
|
||||||
|
assert_eq!(hits.len(), 1);
|
||||||
|
assert_eq!(hits[0].qualified_name, "io.github.github/github-mcp-server");
|
||||||
|
|
||||||
|
// A query that matches nothing returns the unrefined rows (never blank).
|
||||||
|
let none = refine_by_relevance(vec![community.clone()], "github");
|
||||||
|
assert_eq!(none.len(), 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,6 +148,12 @@ pub fn schemas(function: &str) -> ControllerSchema {
|
|||||||
comment: "Free-text search query.",
|
comment: "Free-text search query.",
|
||||||
required: false,
|
required: false,
|
||||||
},
|
},
|
||||||
|
FieldSchema {
|
||||||
|
name: "transport",
|
||||||
|
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||||
|
comment: "Transport filter: \"stdio\", \"hosted\", or \"all\"/omitted.",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
FieldSchema {
|
FieldSchema {
|
||||||
name: "page",
|
name: "page",
|
||||||
ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
|
ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
|
||||||
@@ -671,11 +677,12 @@ fn handle_registry_search(params: Map<String, Value>) -> ControllerFuture {
|
|||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
let config = config_rpc::load_config_with_timeout().await?;
|
let config = config_rpc::load_config_with_timeout().await?;
|
||||||
let query = read_optional_string(¶ms, "query")?;
|
let query = read_optional_string(¶ms, "query")?;
|
||||||
|
let transport = read_optional_string(¶ms, "transport")?;
|
||||||
let page = read_optional_u32(¶ms, "page")?;
|
let page = read_optional_u32(¶ms, "page")?;
|
||||||
let page_size = read_optional_u32(¶ms, "page_size")?;
|
let page_size = read_optional_u32(¶ms, "page_size")?;
|
||||||
to_json(
|
to_json(
|
||||||
crate::openhuman::mcp_registry::ops::mcp_clients_registry_search(
|
crate::openhuman::mcp_registry::ops::mcp_clients_registry_search(
|
||||||
&config, query, page, page_size,
|
&config, query, transport, page, page_size,
|
||||||
)
|
)
|
||||||
.await?,
|
.await?,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ pub async fn mcp_setup_search(
|
|||||||
let page = page.unwrap_or(1);
|
let page = page.unwrap_or(1);
|
||||||
let page_size = page_size.unwrap_or(20);
|
let page_size = page_size.unwrap_or(20);
|
||||||
let (servers, total_pages) =
|
let (servers, total_pages) =
|
||||||
registry::registry_search(config, query.as_deref(), page, page_size)
|
registry::registry_search(config, query.as_deref(), None, page, page_size)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
Ok(RpcOutcome::new(
|
Ok(RpcOutcome::new(
|
||||||
|
|||||||
@@ -56,14 +56,16 @@ impl Tool for McpRegistrySearchTool {
|
|||||||
"mcp_registry_search"
|
"mcp_registry_search"
|
||||||
}
|
}
|
||||||
fn description(&self) -> &str {
|
fn description(&self) -> &str {
|
||||||
"Search the MCP server registry catalog by `query`, paginated by `page` \
|
"Search the MCP server registry catalog by `query`, optionally filtered by \
|
||||||
/ `page_size`. Use to discover installable MCP servers."
|
`transport` (\"stdio\" | \"hosted\" | \"all\"), paginated by `page` / \
|
||||||
|
`page_size`. Use to discover installable MCP servers."
|
||||||
}
|
}
|
||||||
fn parameters_schema(&self) -> serde_json::Value {
|
fn parameters_schema(&self) -> serde_json::Value {
|
||||||
json!({
|
json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"query": { "type": "string" },
|
"query": { "type": "string" },
|
||||||
|
"transport": { "type": "string", "enum": ["stdio", "hosted", "all"] },
|
||||||
"page": { "type": "integer", "minimum": 1 },
|
"page": { "type": "integer", "minimum": 1 },
|
||||||
"page_size": { "type": "integer", "minimum": 1 }
|
"page_size": { "type": "integer", "minimum": 1 }
|
||||||
}
|
}
|
||||||
@@ -79,8 +81,12 @@ impl Tool for McpRegistrySearchTool {
|
|||||||
.get("page_size")
|
.get("page_size")
|
||||||
.and_then(Value::as_u64)
|
.and_then(Value::as_u64)
|
||||||
.map(|v| v as u32);
|
.map(|v| v as u32);
|
||||||
|
let transport = args
|
||||||
|
.get("transport")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::to_string);
|
||||||
emit!(
|
emit!(
|
||||||
ops::mcp_clients_registry_search(&self.config, query, page, page_size).await,
|
ops::mcp_clients_registry_search(&self.config, query, transport, page, page_size).await,
|
||||||
"mcp_registry_search"
|
"mcp_registry_search"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -253,6 +253,23 @@ pub struct SmitheryServerSummary {
|
|||||||
/// the dispatcher; never trusted from the wire.
|
/// the dispatcher; never trusted from the wire.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub official: bool,
|
pub official: bool,
|
||||||
|
/// Vendor/site URL declared by the server, when present. A trust/quality
|
||||||
|
/// signal: the strict catalog filter requires it and the UI renders it as a
|
||||||
|
/// clickable link. `None` for servers that declare no website (and for
|
||||||
|
/// Smithery summaries, which don't carry one). Set by the registry adapter;
|
||||||
|
/// never deserialized from the wire (`skip_deserializing`) so a payload that
|
||||||
|
/// starts emitting the key can't spoof the strict curation filter.
|
||||||
|
#[serde(default, skip_deserializing)]
|
||||||
|
pub website_url: Option<String>,
|
||||||
|
/// Declared auth method derived from registry metadata: `Some("api_key")`
|
||||||
|
/// when the server declares a named static secret (an `isSecret` /
|
||||||
|
/// `Authorization` header or an `isSecret` env var). `None` when no static
|
||||||
|
/// credential is declared (open, OAuth-only, or under-specified). Set by the
|
||||||
|
/// official adapter; never deserialized from the wire (`skip_deserializing`)
|
||||||
|
/// so a payload that starts emitting the key can't spoof the strict curation
|
||||||
|
/// filter.
|
||||||
|
#[serde(default, skip_deserializing)]
|
||||||
|
pub auth_kind: Option<String>,
|
||||||
/// Raw extra fields preserved for future use.
|
/// Raw extra fields preserved for future use.
|
||||||
#[serde(flatten, default)]
|
#[serde(flatten, default)]
|
||||||
pub extra: std::collections::HashMap<String, Value>,
|
pub extra: std::collections::HashMap<String, Value>,
|
||||||
@@ -501,6 +518,27 @@ mod tests {
|
|||||||
assert!(s.is_deployed);
|
assert!(s.is_deployed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `website_url`/`auth_kind` are adapter-derived trust signals that drive the
|
||||||
|
/// strict "perfect server" filter and the UI. They must NEVER be honored from
|
||||||
|
/// the wire — `skip_deserializing` forces them to `None` on any parse so a
|
||||||
|
/// payload that starts emitting the keys can't spoof curation. Pins the
|
||||||
|
/// annotation so a future serde change can't silently re-admit them.
|
||||||
|
#[test]
|
||||||
|
fn smithery_summary_never_deserializes_trust_signals_from_the_wire() {
|
||||||
|
let raw = json!({
|
||||||
|
"qualifiedName": "@evil/server",
|
||||||
|
"displayName": "Evil",
|
||||||
|
"website_url": "https://spoofed.example",
|
||||||
|
"auth_kind": "api_key",
|
||||||
|
});
|
||||||
|
let s: SmitheryServerSummary = serde_json::from_value(raw).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
s.website_url, None,
|
||||||
|
"website_url must not come from the wire"
|
||||||
|
);
|
||||||
|
assert_eq!(s.auth_kind, None, "auth_kind must not come from the wire");
|
||||||
|
}
|
||||||
|
|
||||||
/// RPC responses to the frontend must use snake_case field names.
|
/// RPC responses to the frontend must use snake_case field names.
|
||||||
/// This pins the serialization format so a future serde annotation
|
/// This pins the serialization format so a future serde annotation
|
||||||
/// change doesn't silently break the frontend.
|
/// change doesn't silently break the frontend.
|
||||||
@@ -515,6 +553,8 @@ mod tests {
|
|||||||
is_deployed: true,
|
is_deployed: true,
|
||||||
source: "mcp_official".to_string(),
|
source: "mcp_official".to_string(),
|
||||||
official: false,
|
official: false,
|
||||||
|
website_url: None,
|
||||||
|
auth_kind: None,
|
||||||
extra: Default::default(),
|
extra: Default::default(),
|
||||||
};
|
};
|
||||||
let v = serde_json::to_value(&s).unwrap();
|
let v = serde_json::to_value(&s).unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user