+
{definitions.map(def => {
const channelId = def.id as ChannelType;
const isSelected = selectedChannel === channelId;
@@ -81,6 +91,25 @@ const ChannelSelector = ({
);
})}
+
+ {/* Virtual tabs — not backed by a ChannelDefinition from the core */}
+ {VIRTUAL_TABS.map(tab => {
+ const isSelected = selectedChannel === tab.id;
+ return (
+
+ );
+ })}
);
diff --git a/app/src/components/channels/mcp/ConfigAssistantPanel.test.tsx b/app/src/components/channels/mcp/ConfigAssistantPanel.test.tsx
new file mode 100644
index 000000000..11e28a087
--- /dev/null
+++ b/app/src/components/channels/mcp/ConfigAssistantPanel.test.tsx
@@ -0,0 +1,135 @@
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import ConfigAssistantPanel from './ConfigAssistantPanel';
+
+const mockConfigAssist = vi.fn();
+
+vi.mock('../../../services/api/mcpClientsApi', () => ({
+ mcpClientsApi: { configAssist: (...args: unknown[]) => mockConfigAssist(...args) },
+}));
+
+describe('ConfigAssistantPanel', () => {
+ beforeEach(() => {
+ mockConfigAssist.mockReset();
+ });
+
+ it('renders the input textarea and Send button', () => {
+ render(
);
+ expect(screen.getByPlaceholderText(/ask a question/i)).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Send' })).toBeInTheDocument();
+ });
+
+ it('send button is disabled when input is empty', () => {
+ render(
);
+ expect(screen.getByRole('button', { name: 'Send' })).toBeDisabled();
+ });
+
+ it('enables send button when input has text', () => {
+ render(
);
+ fireEvent.change(screen.getByPlaceholderText(/ask a question/i), {
+ target: { value: 'What env vars do I need?' },
+ });
+ expect(screen.getByRole('button', { name: 'Send' })).not.toBeDisabled();
+ });
+
+ it('sends message and renders assistant reply', async () => {
+ mockConfigAssist.mockResolvedValue({ reply: 'You need an API_KEY env var.' });
+ render(
);
+
+ fireEvent.change(screen.getByPlaceholderText(/ask a question/i), {
+ target: { value: 'What do I need?' },
+ });
+
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'Send' }));
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText('You need an API_KEY env var.')).toBeInTheDocument();
+ });
+
+ expect(mockConfigAssist).toHaveBeenCalledWith({
+ qualified_name: 'acme/test',
+ user_message: 'What do I need?',
+ history: [{ role: 'user', content: 'What do I need?' }],
+ });
+ });
+
+ it('shows suggested_env values and Apply button', async () => {
+ mockConfigAssist.mockResolvedValue({
+ reply: 'Here are suggested values',
+ suggested_env: { API_KEY: 'abc123' },
+ });
+
+ const onApply = vi.fn();
+ render(
);
+
+ fireEvent.change(screen.getByPlaceholderText(/ask a question/i), {
+ target: { value: 'Help me configure' },
+ });
+
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'Send' }));
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText('Here are suggested values')).toBeInTheDocument();
+ });
+
+ // Shows key name (the colon is in the same text node with whitespace)
+ expect(screen.getByText(/API_KEY:/)).toBeInTheDocument();
+
+ // Apply button exists and calls the callback
+ const applyBtn = screen.getByRole('button', { name: 'Apply suggested values' });
+ fireEvent.click(applyBtn);
+ expect(onApply).toHaveBeenCalledWith({ API_KEY: 'abc123' });
+ });
+
+ it('shows error on failed request', async () => {
+ mockConfigAssist.mockRejectedValue(new Error('AI service unavailable'));
+ render(
);
+
+ fireEvent.change(screen.getByPlaceholderText(/ask a question/i), {
+ target: { value: 'Hello' },
+ });
+
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'Send' }));
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText('AI service unavailable')).toBeInTheDocument();
+ });
+ });
+
+ it('clears input after sending', async () => {
+ mockConfigAssist.mockResolvedValue({ reply: 'OK' });
+ render(
);
+
+ const textarea = screen.getByPlaceholderText(/ask a question/i) as HTMLTextAreaElement;
+ fireEvent.change(textarea, { target: { value: 'Question?' } });
+
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'Send' }));
+ });
+
+ expect(textarea.value).toBe('');
+ });
+
+ it('sends on Enter key press', async () => {
+ mockConfigAssist.mockResolvedValue({ reply: 'reply' });
+ render(
);
+
+ const textarea = screen.getByPlaceholderText(/ask a question/i);
+ fireEvent.change(textarea, { target: { value: 'test message' } });
+
+ await act(async () => {
+ fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false });
+ });
+
+ await waitFor(() => {
+ expect(mockConfigAssist).toHaveBeenCalledTimes(1);
+ });
+ });
+});
diff --git a/app/src/components/channels/mcp/ConfigAssistantPanel.tsx b/app/src/components/channels/mcp/ConfigAssistantPanel.tsx
new file mode 100644
index 000000000..c52243a40
--- /dev/null
+++ b/app/src/components/channels/mcp/ConfigAssistantPanel.tsx
@@ -0,0 +1,181 @@
+/**
+ * Inline LLM-driven configuration assistant chat.
+ * Maintains a local message history and calls config_assist with each send.
+ * If the reply includes suggested_env, shows an "Apply suggested values" button
+ * that passes them up to the caller (e.g. to pre-fill the install dialog).
+ */
+import debug from 'debug';
+import { useCallback, useRef, useState } from 'react';
+
+import { mcpClientsApi } from '../../../services/api/mcpClientsApi';
+
+const log = debug('mcp-clients:config-assist');
+
+interface Message {
+ role: 'user' | 'assistant';
+ content: string;
+ suggested_env?: Record
;
+}
+
+interface ConfigAssistantPanelProps {
+ qualifiedName: string;
+ onApplySuggestedEnv?: (env: Record) => void;
+}
+
+const ConfigAssistantPanel = ({
+ qualifiedName,
+ onApplySuggestedEnv,
+}: ConfigAssistantPanelProps) => {
+ const [messages, setMessages] = useState([]);
+ const [input, setInput] = useState('');
+ const [sending, setSending] = useState(false);
+ const [error, setError] = useState(null);
+ const bottomRef = useRef(null);
+
+ const scrollToBottom = useCallback(() => {
+ bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
+ }, []);
+
+ const handleSend = useCallback(async () => {
+ const text = input.trim();
+ if (!text || sending) return;
+
+ const userMessage: Message = { role: 'user', content: text };
+ const updatedHistory = [...messages, userMessage];
+ setMessages(updatedHistory);
+ setInput('');
+ setSending(true);
+ setError(null);
+ log('sending message: %s', text);
+
+ try {
+ const result = await mcpClientsApi.configAssist({
+ qualified_name: qualifiedName,
+ user_message: text,
+ history: updatedHistory.map(m => ({ role: m.role, content: m.content })),
+ });
+ log(
+ 'received reply length=%d suggested_env=%s',
+ result.reply.length,
+ result.suggested_env ? 'yes' : 'no'
+ );
+
+ const assistantMessage: Message = {
+ role: 'assistant',
+ content: result.reply,
+ suggested_env: result.suggested_env,
+ };
+ setMessages(prev => [...prev, assistantMessage]);
+ setTimeout(scrollToBottom, 50);
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : 'Failed to get response';
+ log('config_assist error: %s', msg);
+ setError(msg);
+ } finally {
+ setSending(false);
+ }
+ }, [input, messages, qualifiedName, sending, scrollToBottom]);
+
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ void handleSend();
+ }
+ },
+ [handleSend]
+ );
+
+ return (
+
+
+ Configuration assistant
+
+
+ {/* Message list */}
+
+ {messages.length === 0 && (
+
+ Ask about configuration, required env vars, or setup steps.
+
+ )}
+ {messages.map((msg, idx) => (
+
+
+
{msg.content}
+ {msg.suggested_env && Object.keys(msg.suggested_env).length > 0 && (
+
+
Suggested values:
+
+ {Object.keys(msg.suggested_env).map(key => (
+ -
+ {key}: (value hidden)
+
+ ))}
+
+ {onApplySuggestedEnv && (
+
+ )}
+ {!onApplySuggestedEnv && (
+
+ Re-install with these values to apply them.
+
+ )}
+
+ )}
+
+
+ ))}
+ {sending && (
+
+ )}
+
+
+
+ {/* Error */}
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Input row */}
+
+
+
+ );
+};
+
+export default ConfigAssistantPanel;
diff --git a/app/src/components/channels/mcp/InstallDialog.test.tsx b/app/src/components/channels/mcp/InstallDialog.test.tsx
new file mode 100644
index 000000000..2016c3743
--- /dev/null
+++ b/app/src/components/channels/mcp/InstallDialog.test.tsx
@@ -0,0 +1,179 @@
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import InstallDialog from './InstallDialog';
+
+const mockRegistryGet = vi.fn();
+const mockInstall = vi.fn();
+
+vi.mock('../../../services/api/mcpClientsApi', () => ({
+ mcpClientsApi: {
+ registryGet: (...args: unknown[]) => mockRegistryGet(...args),
+ install: (...args: unknown[]) => mockInstall(...args),
+ },
+}));
+
+const DETAIL = {
+ qualified_name: 'acme/test-server',
+ display_name: 'Test Server',
+ description: 'A test server',
+ connections: [],
+ required_env_keys: ['API_KEY', 'SECRET_TOKEN'],
+};
+
+describe('InstallDialog', () => {
+ beforeEach(() => {
+ mockRegistryGet.mockReset();
+ mockInstall.mockReset();
+ });
+
+ it('shows loading state while fetching detail', () => {
+ // Never resolves within the test
+ mockRegistryGet.mockReturnValue(new Promise(() => {}));
+ render(
+ {}} onCancel={() => {}} />
+ );
+ expect(screen.getByText('Loading server details...')).toBeInTheDocument();
+ });
+
+ it('renders env key inputs from registry_get', async () => {
+ mockRegistryGet.mockResolvedValue(DETAIL);
+ render(
+ {}} onCancel={() => {}} />
+ );
+
+ await waitFor(() => {
+ expect(screen.getByLabelText('API_KEY')).toBeInTheDocument();
+ });
+ expect(screen.getByLabelText('SECRET_TOKEN')).toBeInTheDocument();
+ });
+
+ it('renders env inputs as password type by default', async () => {
+ mockRegistryGet.mockResolvedValue(DETAIL);
+ render(
+ {}} onCancel={() => {}} />
+ );
+
+ await waitFor(() => screen.getByLabelText('API_KEY'));
+
+ const input = screen.getByLabelText('API_KEY') as HTMLInputElement;
+ expect(input.type).toBe('password');
+ });
+
+ it('toggles env input to text on Show click', async () => {
+ mockRegistryGet.mockResolvedValue(DETAIL);
+ render(
+ {}} onCancel={() => {}} />
+ );
+
+ await waitFor(() => screen.getByLabelText('API_KEY'));
+
+ const showButtons = screen.getAllByRole('button', { name: 'Show' });
+ fireEvent.click(showButtons[0]);
+
+ const input = screen.getByLabelText('API_KEY') as HTMLInputElement;
+ expect(input.type).toBe('text');
+ });
+
+ it('calls install with filled values on submit', async () => {
+ const installedServer = {
+ server_id: 'srv-1',
+ ...DETAIL,
+ command_kind: 'node' as const,
+ command: 'node',
+ args: [],
+ env_keys: ['API_KEY', 'SECRET_TOKEN'],
+ installed_at: 1000,
+ };
+ mockRegistryGet.mockResolvedValue(DETAIL);
+ mockInstall.mockResolvedValue(installedServer);
+
+ const onSuccess = vi.fn();
+ render(
+ {}} />
+ );
+
+ await waitFor(() => screen.getByLabelText('API_KEY'));
+
+ fireEvent.change(screen.getByLabelText('API_KEY'), { target: { value: 'my-api-key' } });
+ fireEvent.change(screen.getByLabelText('SECRET_TOKEN'), { target: { value: 'my-secret' } });
+
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'Install' }));
+ });
+
+ expect(mockInstall).toHaveBeenCalledWith({
+ qualified_name: 'acme/test-server',
+ env: { API_KEY: 'my-api-key', SECRET_TOKEN: 'my-secret' },
+ config: undefined,
+ });
+ expect(onSuccess).toHaveBeenCalledWith(installedServer);
+ });
+
+ it('shows validation error when required field is empty', async () => {
+ mockRegistryGet.mockResolvedValue(DETAIL);
+ render(
+ {}} onCancel={() => {}} />
+ );
+
+ await waitFor(() => screen.getByLabelText('API_KEY'));
+
+ // Leave API_KEY empty, fill only SECRET_TOKEN
+ fireEvent.change(screen.getByLabelText('SECRET_TOKEN'), { target: { value: 'secret' } });
+
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'Install' }));
+ });
+
+ expect(screen.getByText('"API_KEY" is required')).toBeInTheDocument();
+ expect(mockInstall).not.toHaveBeenCalled();
+ });
+
+ it('shows install error on failure', async () => {
+ mockRegistryGet.mockResolvedValue(DETAIL);
+ mockInstall.mockRejectedValue(new Error('Server error'));
+
+ render(
+ {}} onCancel={() => {}} />
+ );
+
+ await waitFor(() => screen.getByLabelText('API_KEY'));
+
+ fireEvent.change(screen.getByLabelText('API_KEY'), { target: { value: 'key' } });
+ fireEvent.change(screen.getByLabelText('SECRET_TOKEN'), { target: { value: 'secret' } });
+
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'Install' }));
+ });
+
+ await waitFor(() => screen.getByText('Server error'));
+ });
+
+ it('calls onCancel when Cancel is clicked', async () => {
+ mockRegistryGet.mockResolvedValue(DETAIL);
+ const onCancel = vi.fn();
+ render(
+ {}} onCancel={onCancel} />
+ );
+
+ await waitFor(() => screen.getByRole('button', { name: 'Cancel' }));
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
+ expect(onCancel).toHaveBeenCalledTimes(1);
+ });
+
+ it('pre-fills env values from prefillEnv prop', async () => {
+ mockRegistryGet.mockResolvedValue(DETAIL);
+ render(
+ {}}
+ onCancel={() => {}}
+ />
+ );
+
+ await waitFor(() => screen.getByLabelText('API_KEY'));
+ const input = screen.getByLabelText('API_KEY') as HTMLInputElement;
+ expect(input.value).toBe('prefilled-key');
+ });
+});
diff --git a/app/src/components/channels/mcp/InstallDialog.tsx b/app/src/components/channels/mcp/InstallDialog.tsx
new file mode 100644
index 000000000..c242c3028
--- /dev/null
+++ b/app/src/components/channels/mcp/InstallDialog.tsx
@@ -0,0 +1,261 @@
+/**
+ * Install dialog for a Smithery server.
+ * Fetches the server detail, renders env-key inputs (password type with
+ * show/hide toggle), an optional raw-JSON config textarea, and calls
+ * `install` on submit.
+ */
+import debug from 'debug';
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+import { mcpClientsApi } from '../../../services/api/mcpClientsApi';
+import type { InstalledServer, SmitheryServerDetail } from './types';
+
+const log = debug('mcp-clients:install');
+
+interface InstallDialogProps {
+ qualifiedName: string;
+ prefillEnv?: Record;
+ onSuccess: (server: InstalledServer) => void;
+ onCancel: () => void;
+}
+
+const InstallDialog = ({ qualifiedName, prefillEnv, onSuccess, onCancel }: InstallDialogProps) => {
+ const [detail, setDetail] = useState(null);
+ const [loadingDetail, setLoadingDetail] = useState(true);
+ const [detailError, setDetailError] = useState(null);
+
+ const [envValues, setEnvValues] = useState>({});
+ const [showEnv, setShowEnv] = useState>({});
+ const [configJson, setConfigJson] = useState('');
+
+ const [installing, setInstalling] = useState(false);
+ const [installError, setInstallError] = useState(null);
+
+ // Track the latest qualifiedName seen by the effect to guard against stale
+ // async responses when qualifiedName changes or the component unmounts.
+ const latestQualifiedNameRef = useRef(qualifiedName);
+
+ // Fetch server detail on mount or when qualifiedName changes.
+ useEffect(() => {
+ latestQualifiedNameRef.current = qualifiedName;
+ setLoadingDetail(true);
+ setDetailError(null);
+ log('fetching detail for %s', qualifiedName);
+ const requestedName = qualifiedName;
+ mcpClientsApi
+ .registryGet(qualifiedName)
+ .then(d => {
+ // Discard response if a newer request has already been issued.
+ if (latestQualifiedNameRef.current !== requestedName) {
+ log('discarding stale detail response for %s', requestedName);
+ return;
+ }
+ setDetail(d);
+ // Pre-fill env values from prop (suggested by config assistant) or empty.
+ const initial: Record = {};
+ for (const key of d.required_env_keys ?? []) {
+ initial[key] = prefillEnv?.[key] ?? '';
+ }
+ setEnvValues(initial);
+ log('detail loaded, required_env_keys=%o', d.required_env_keys);
+ })
+ .catch(err => {
+ if (latestQualifiedNameRef.current !== requestedName) return;
+ const msg = err instanceof Error ? err.message : 'Failed to load server details';
+ log('detail error: %s', msg);
+ setDetailError(msg);
+ })
+ .finally(() => {
+ if (latestQualifiedNameRef.current === requestedName) {
+ setLoadingDetail(false);
+ }
+ });
+ }, [qualifiedName, prefillEnv]);
+
+ const toggleShowEnv = useCallback((key: string) => {
+ setShowEnv(prev => ({ ...prev, [key]: !prev[key] }));
+ }, []);
+
+ const handleEnvChange = useCallback((key: string, value: string) => {
+ setEnvValues(prev => ({ ...prev, [key]: value }));
+ }, []);
+
+ const handleInstall = useCallback(async () => {
+ if (!detail) return;
+
+ // Validate required keys are filled.
+ for (const key of detail.required_env_keys ?? []) {
+ if (!envValues[key]?.trim()) {
+ setInstallError(`"${key}" is required`);
+ return;
+ }
+ }
+
+ // Parse optional JSON config.
+ let parsedConfig: unknown = undefined;
+ if (configJson.trim()) {
+ try {
+ parsedConfig = JSON.parse(configJson.trim());
+ } catch {
+ setInstallError('Config JSON is not valid JSON');
+ return;
+ }
+ }
+
+ setInstalling(true);
+ setInstallError(null);
+ log('installing %s', qualifiedName);
+
+ try {
+ const server = await mcpClientsApi.install({
+ qualified_name: qualifiedName,
+ env: envValues,
+ config: parsedConfig,
+ });
+ log('install success server_id=%s', server.server_id);
+ onSuccess(server);
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : 'Install failed';
+ log('install error: %s', msg);
+ setInstallError(msg);
+ } finally {
+ setInstalling(false);
+ }
+ }, [detail, envValues, configJson, qualifiedName, onSuccess]);
+
+ if (loadingDetail) {
+ return (
+
+ Loading server details...
+
+ );
+ }
+
+ if (detailError) {
+ return (
+
+
+ {detailError}
+
+
+
+ );
+ }
+
+ if (!detail) return null;
+
+ return (
+
+ {/* Header */}
+
+ {detail.icon_url ? (
+

+ ) : (
+
+ 🔌
+
+ )}
+
+
+ Install {detail.display_name}
+
+ {detail.description && (
+
+ {detail.description}
+
+ )}
+
+
+
+ {/* Env var inputs */}
+ {(detail.required_env_keys ?? []).length > 0 && (
+
+
+ Required environment variables
+
+ {detail.required_env_keys!.map(key => (
+
+
+
+ handleEnvChange(key, e.target.value)}
+ placeholder={`Enter ${key}`}
+ disabled={installing}
+ className="flex-1 rounded-lg border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-1.5 text-sm text-stone-800 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:outline-none focus:ring-2 focus:ring-primary-500/40 disabled:opacity-50"
+ />
+
+
+
+ ))}
+
+ )}
+
+ {/* Optional JSON config */}
+
+
+
+
+ {/* Error */}
+ {installError && (
+
+ {installError}
+
+ )}
+
+ {/* Actions */}
+
+
+
+
+
+ );
+};
+
+export default InstallDialog;
diff --git a/app/src/components/channels/mcp/InstalledServerDetail.test.tsx b/app/src/components/channels/mcp/InstalledServerDetail.test.tsx
new file mode 100644
index 000000000..272e4cb7d
--- /dev/null
+++ b/app/src/components/channels/mcp/InstalledServerDetail.test.tsx
@@ -0,0 +1,190 @@
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import InstalledServerDetail from './InstalledServerDetail';
+
+const mockConnect = vi.fn();
+const mockDisconnect = vi.fn();
+const mockUninstall = vi.fn();
+
+vi.mock('../../../services/api/mcpClientsApi', () => ({
+ mcpClientsApi: {
+ connect: (...args: unknown[]) => mockConnect(...args),
+ disconnect: (...args: unknown[]) => mockDisconnect(...args),
+ uninstall: (...args: unknown[]) => mockUninstall(...args),
+ configAssist: vi.fn(),
+ },
+}));
+
+const BASE_SERVER = {
+ server_id: 'srv-1',
+ qualified_name: 'acme/test-server',
+ display_name: 'Test Server',
+ description: 'A test MCP server',
+ command_kind: 'node' as const,
+ command: 'node',
+ args: [],
+ env_keys: ['API_KEY', 'DB_URL'],
+ installed_at: 1_700_000_000,
+};
+
+describe('InstalledServerDetail', () => {
+ beforeEach(() => {
+ mockConnect.mockReset();
+ mockDisconnect.mockReset();
+ mockUninstall.mockReset();
+ });
+
+ it('renders server name and description', () => {
+ render(
+ {}} />
+ );
+ expect(screen.getByText('Test Server')).toBeInTheDocument();
+ expect(screen.getByText('A test MCP server')).toBeInTheDocument();
+ });
+
+ it('shows env key names', () => {
+ render(
+ {}} />
+ );
+ expect(screen.getByText('API_KEY')).toBeInTheDocument();
+ expect(screen.getByText('DB_URL')).toBeInTheDocument();
+ });
+
+ it('shows Connect button when disconnected', () => {
+ render(
+ {}}
+ />
+ );
+ expect(screen.getByRole('button', { name: 'Connect' })).toBeInTheDocument();
+ });
+
+ it('shows Disconnect button when connected', () => {
+ render(
+ {}}
+ />
+ );
+ expect(screen.getByRole('button', { name: 'Disconnect' })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Connect' })).not.toBeInTheDocument();
+ });
+
+ it('calls connect on Connect click', async () => {
+ mockConnect.mockResolvedValue({ server_id: 'srv-1', status: 'connected', tools: [] });
+ render(
+ {}} />
+ );
+
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'Connect' }));
+ });
+
+ expect(mockConnect).toHaveBeenCalledWith('srv-1');
+ });
+
+ it('calls disconnect on Disconnect click', async () => {
+ mockDisconnect.mockResolvedValue({ server_id: 'srv-1', status: 'disconnected' });
+ render(
+ {}}
+ />
+ );
+
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'Disconnect' }));
+ });
+
+ expect(mockDisconnect).toHaveBeenCalledWith('srv-1');
+ });
+
+ it('shows confirm prompt before uninstalling', () => {
+ render(
+ {}} />
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Uninstall' }));
+ expect(screen.getByRole('button', { name: 'Yes, uninstall' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
+ });
+
+ it('calls uninstall and onUninstalled after confirm', async () => {
+ mockUninstall.mockResolvedValue({ server_id: 'srv-1', removed: true });
+ const onUninstalled = vi.fn();
+ render(
+
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Uninstall' }));
+
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'Yes, uninstall' }));
+ });
+
+ await waitFor(() => {
+ expect(mockUninstall).toHaveBeenCalledWith('srv-1');
+ expect(onUninstalled).toHaveBeenCalledWith('srv-1');
+ });
+ });
+
+ it('shows connect error inline', async () => {
+ mockConnect.mockRejectedValue(new Error('Connection refused'));
+ render(
+ {}} />
+ );
+
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'Connect' }));
+ });
+
+ await waitFor(() => screen.getByText('Connection refused'));
+ });
+
+ it('renders status badge from connStatus', () => {
+ render(
+ {}}
+ />
+ );
+ expect(screen.getByText('Error')).toBeInTheDocument();
+ // last_error shown in the error banner
+ expect(screen.getByText('Timed out')).toBeInTheDocument();
+ });
+});
diff --git a/app/src/components/channels/mcp/InstalledServerDetail.tsx b/app/src/components/channels/mcp/InstalledServerDetail.tsx
new file mode 100644
index 000000000..f7035ec08
--- /dev/null
+++ b/app/src/components/channels/mcp/InstalledServerDetail.tsx
@@ -0,0 +1,229 @@
+/**
+ * Detail view for a single installed MCP server.
+ * Shows header, status, env key names (never values), tool list, and action buttons.
+ */
+import debug from 'debug';
+import { useCallback, useState } from 'react';
+
+import { mcpClientsApi } from '../../../services/api/mcpClientsApi';
+import ConfigAssistantPanel from './ConfigAssistantPanel';
+import McpStatusBadge from './McpStatusBadge';
+import McpToolList from './McpToolList';
+import type { ConnStatus, InstalledServer, McpTool, ServerStatus } from './types';
+
+const log = debug('mcp-clients:detail');
+
+interface InstalledServerDetailProps {
+ server: InstalledServer;
+ connStatus: ConnStatus | undefined;
+ onUninstalled: (serverId: string) => void;
+}
+
+const InstalledServerDetail = ({
+ server,
+ connStatus,
+ onUninstalled,
+}: InstalledServerDetailProps) => {
+ const status: ServerStatus = connStatus?.status ?? 'disconnected';
+ const [tools, setTools] = useState([]);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+ const [confirmUninstall, setConfirmUninstall] = useState(false);
+ const [showAssistant, setShowAssistant] = useState(false);
+ const [suggestedEnv, setSuggestedEnv] = useState | null>(null);
+
+ const runBusy = useCallback(async (task: () => Promise) => {
+ setBusy(true);
+ setError(null);
+ try {
+ await task();
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err);
+ log('error: %s', msg);
+ setError(msg);
+ } finally {
+ setBusy(false);
+ }
+ }, []);
+
+ const handleConnect = useCallback(() => {
+ void runBusy(async () => {
+ log('connecting server_id=%s', server.server_id);
+ const result = await mcpClientsApi.connect(server.server_id);
+ setTools(result.tools);
+ log('connected, %d tools', result.tools.length);
+ });
+ }, [server.server_id, runBusy]);
+
+ const handleDisconnect = useCallback(() => {
+ void runBusy(async () => {
+ log('disconnecting server_id=%s', server.server_id);
+ await mcpClientsApi.disconnect(server.server_id);
+ // Clear stale tool list so it doesn't show after disconnection.
+ setTools([]);
+ log('disconnected');
+ });
+ }, [server.server_id, runBusy]);
+
+ const handleUninstall = useCallback(() => {
+ void runBusy(async () => {
+ log('uninstalling server_id=%s', server.server_id);
+ await mcpClientsApi.uninstall(server.server_id);
+ log('uninstalled');
+ onUninstalled(server.server_id);
+ });
+ }, [server.server_id, runBusy, onUninstalled]);
+
+ const handleApplySuggestedEnv = useCallback((env: Record) => {
+ setSuggestedEnv(env);
+ log('suggested_env applied, keys=%o', Object.keys(env));
+ }, []);
+
+ return (
+
+ {/* Header */}
+
+ {server.icon_url ? (
+

+ ) : (
+
+ 🔌
+
+ )}
+
+
+
+ {server.display_name}
+
+
+
+ {server.description && (
+
+ {server.description}
+
+ )}
+
+ {server.qualified_name}
+
+
+
+
+ {/* Error */}
+ {(error || connStatus?.last_error) && (
+
+ {error ?? connStatus?.last_error}
+
+ )}
+
+ {/* Suggested env notice */}
+ {suggestedEnv && (
+
+
Suggested environment values ready
+
+ Re-install this server with the suggested values to apply them:{' '}
+ {Object.keys(suggestedEnv).join(', ')}
+
+
+ )}
+
+ {/* Action buttons */}
+
+ {status !== 'connected' ? (
+
+ ) : (
+
+ )}
+
+
+
+ {confirmUninstall ? (
+
+
+ Confirm uninstall?
+
+
+
+
+ ) : (
+
+ )}
+
+
+ {/* Env keys (names only) */}
+ {server.env_keys.length > 0 && (
+
+
+ Environment variables
+
+
+ {server.env_keys.map(key => (
+
+ {key}
+
+ ))}
+
+
+ )}
+
+ {/* Tool list — only show when connected so stale tools don't linger */}
+
+
+ {/* Config assistant */}
+ {showAssistant && (
+
+
+
+ )}
+
+ );
+};
+
+export default InstalledServerDetail;
diff --git a/app/src/components/channels/mcp/InstalledServerList.test.tsx b/app/src/components/channels/mcp/InstalledServerList.test.tsx
new file mode 100644
index 000000000..0ee1f830d
--- /dev/null
+++ b/app/src/components/channels/mcp/InstalledServerList.test.tsx
@@ -0,0 +1,218 @@
+/**
+ * Tests for InstalledServerList — static rendering component.
+ * No async behavior; all branches covered synchronously.
+ */
+import { fireEvent, render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+import InstalledServerList from './InstalledServerList';
+import type { ConnStatus, InstalledServer } from './types';
+
+const SERVER_1: InstalledServer = {
+ server_id: 'srv-1',
+ qualified_name: 'acme/fs-server',
+ display_name: 'File Server',
+ description: 'Reads files',
+ command_kind: 'node',
+ command: 'npx',
+ args: ['-y', 'acme/fs-server'],
+ env_keys: [],
+ installed_at: 1_700_000_000,
+};
+
+const SERVER_2: InstalledServer = {
+ server_id: 'srv-2',
+ qualified_name: 'acme/db-server',
+ display_name: 'DB Server',
+ description: undefined,
+ command_kind: 'node',
+ command: 'npx',
+ args: ['-y', 'acme/db-server'],
+ env_keys: ['DB_URL'],
+ installed_at: 1_700_000_001,
+};
+
+const STATUS_CONNECTED: ConnStatus = {
+ server_id: 'srv-1',
+ qualified_name: 'acme/fs-server',
+ display_name: 'File Server',
+ status: 'connected',
+ tool_count: 3,
+};
+
+const STATUS_ERROR: ConnStatus = {
+ server_id: 'srv-2',
+ qualified_name: 'acme/db-server',
+ display_name: 'DB Server',
+ status: 'error',
+ tool_count: 0,
+ last_error: 'Connection refused',
+};
+
+describe('InstalledServerList', () => {
+ it('shows empty state with Browse catalog button when no servers', () => {
+ const onBrowse = vi.fn();
+ render(
+ {}}
+ onBrowseCatalog={onBrowse}
+ />
+ );
+ expect(screen.getByText('No MCP servers installed yet.')).toBeInTheDocument();
+ // Two "Browse catalog" buttons exist: header link and empty-state CTA.
+ // Click the CTA (second one) to verify the prop fires.
+ const btns = screen.getAllByRole('button', { name: 'Browse catalog' });
+ expect(btns).toHaveLength(2);
+ fireEvent.click(btns[1]);
+ expect(onBrowse).toHaveBeenCalledTimes(1);
+ });
+
+ it('renders all server display names', () => {
+ render(
+ {}}
+ onBrowseCatalog={() => {}}
+ />
+ );
+ expect(screen.getByText('File Server')).toBeInTheDocument();
+ expect(screen.getByText('DB Server')).toBeInTheDocument();
+ });
+
+ it('calls onSelect with the correct server_id when clicked', () => {
+ const onSelect = vi.fn();
+ render(
+ {}}
+ />
+ );
+ fireEvent.click(screen.getByRole('button', { name: /File Server/i }));
+ expect(onSelect).toHaveBeenCalledWith('srv-1');
+ });
+
+ it('applies selected styling to the active server', () => {
+ render(
+ {}}
+ onBrowseCatalog={() => {}}
+ />
+ );
+ const btn = screen.getByRole('button', { name: /File Server/i });
+ expect(btn.className).toMatch(/border-primary/);
+ });
+
+ it('shows tool count when connected with tools', () => {
+ render(
+ {}}
+ onBrowseCatalog={() => {}}
+ />
+ );
+ expect(screen.getByText('3 tools')).toBeInTheDocument();
+ });
+
+ it('does not show tool count when disconnected', () => {
+ render(
+ {}}
+ onBrowseCatalog={() => {}}
+ />
+ );
+ expect(screen.queryByText(/tools/)).not.toBeInTheDocument();
+ });
+
+ it('does not show tool count when connected but tool_count is 0', () => {
+ render(
+ {}}
+ onBrowseCatalog={() => {}}
+ />
+ );
+ expect(screen.queryByText(/tools/)).not.toBeInTheDocument();
+ });
+
+ it('shows singular "tool" when tool count is 1', () => {
+ render(
+ {}}
+ onBrowseCatalog={() => {}}
+ />
+ );
+ expect(screen.getByText('1 tool')).toBeInTheDocument();
+ });
+
+ it('applies error status dot to error server', () => {
+ render(
+ {}}
+ onBrowseCatalog={() => {}}
+ />
+ );
+ // The status dot has title="error"
+ expect(screen.getByTitle('error')).toBeInTheDocument();
+ });
+
+ it('falls back to disconnected status when no matching status entry', () => {
+ render(
+ {}}
+ onBrowseCatalog={() => {}}
+ />
+ );
+ expect(screen.getByTitle('disconnected')).toBeInTheDocument();
+ });
+
+ it('calls onBrowseCatalog from the header link', () => {
+ const onBrowse = vi.fn();
+ render(
+ {}}
+ onBrowseCatalog={onBrowse}
+ />
+ );
+ // Only the header link button is present when servers are non-empty.
+ fireEvent.click(screen.getByRole('button', { name: 'Browse catalog' }));
+ expect(onBrowse).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/app/src/components/channels/mcp/InstalledServerList.tsx b/app/src/components/channels/mcp/InstalledServerList.tsx
new file mode 100644
index 000000000..025cf90d2
--- /dev/null
+++ b/app/src/components/channels/mcp/InstalledServerList.tsx
@@ -0,0 +1,98 @@
+/**
+ * List of installed MCP servers with status dot, name, and tool count.
+ */
+import type { ConnStatus, InstalledServer, ServerStatus } from './types';
+
+interface InstalledServerListProps {
+ servers: InstalledServer[];
+ statuses: ConnStatus[];
+ selectedId: string | null;
+ onSelect: (serverId: string) => void;
+ onBrowseCatalog: () => void;
+}
+
+const STATUS_DOT: Record = {
+ connected: 'bg-sage-500',
+ connecting: 'bg-amber-400',
+ disconnected: 'bg-stone-300 dark:bg-neutral-600',
+ error: 'bg-coral-500',
+};
+
+const InstalledServerList = ({
+ servers,
+ statuses,
+ selectedId,
+ onSelect,
+ onBrowseCatalog,
+}: InstalledServerListProps) => {
+ const statusMap = new Map(statuses.map(s => [s.server_id, s]));
+
+ return (
+
+
+
+ Installed
+
+
+
+
+ {servers.length === 0 ? (
+
+
+ No MCP servers installed yet.
+
+
+
+ ) : (
+
+ {servers.map(server => {
+ const connStatus = statusMap.get(server.server_id);
+ const status: ServerStatus = connStatus?.status ?? 'disconnected';
+ const toolCount = connStatus?.tool_count ?? 0;
+ const isSelected = selectedId === server.server_id;
+
+ return (
+ -
+
+
+ );
+ })}
+
+ )}
+
+ );
+};
+
+export default InstalledServerList;
diff --git a/app/src/components/channels/mcp/McpCatalogBrowser.test.tsx b/app/src/components/channels/mcp/McpCatalogBrowser.test.tsx
new file mode 100644
index 000000000..f9452e2e3
--- /dev/null
+++ b/app/src/components/channels/mcp/McpCatalogBrowser.test.tsx
@@ -0,0 +1,122 @@
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+// Static import — follows project no-dynamic-import rule for test files.
+import McpCatalogBrowser from './McpCatalogBrowser';
+
+const mockRegistrySearch = vi.fn();
+
+vi.mock('../../../services/api/mcpClientsApi', () => ({
+ mcpClientsApi: { registrySearch: (...args: unknown[]) => mockRegistrySearch(...args) },
+}));
+
+describe('McpCatalogBrowser', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ mockRegistrySearch.mockReset();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('renders search input', async () => {
+ mockRegistrySearch.mockResolvedValue({ servers: [], page: 1, total_pages: 1 });
+ render( {}} />);
+ expect(screen.getByPlaceholderText('Search Smithery catalog...')).toBeInTheDocument();
+ });
+
+ it('fires debounced search on input change', async () => {
+ mockRegistrySearch.mockResolvedValue({ servers: [], page: 1, total_pages: 1 });
+ render( {}} />);
+
+ const input = screen.getByPlaceholderText('Search Smithery catalog...');
+
+ // Advance past the initial debounce
+ await act(async () => {
+ vi.advanceTimersByTime(300);
+ });
+ mockRegistrySearch.mockClear();
+
+ // Type in the search box
+ fireEvent.change(input, { target: { value: 'github' } });
+
+ // Before debounce fires, no new call
+ expect(mockRegistrySearch).not.toHaveBeenCalled();
+
+ // Advance past the 250ms debounce
+ await act(async () => {
+ vi.advanceTimersByTime(300);
+ });
+
+ expect(mockRegistrySearch).toHaveBeenCalledWith({ query: 'github', page: 1, page_size: 20 });
+ });
+
+ it('renders server cards from search results', async () => {
+ const servers = [
+ {
+ qualified_name: 'acme/file-server',
+ display_name: 'File Server',
+ description: 'Reads files',
+ use_count: 100,
+ is_deployed: true,
+ },
+ ];
+ mockRegistrySearch.mockResolvedValue({ servers, page: 1, total_pages: 1 });
+ render( {}} />);
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(300);
+ });
+ // waitFor polls via real setTimeout; switch back so it isn't deadlocked by fake timers.
+ vi.useRealTimers();
+
+ await waitFor(() => {
+ expect(screen.getByText('File Server')).toBeInTheDocument();
+ });
+ expect(screen.getByText('Reads files')).toBeInTheDocument();
+ });
+
+ it('calls onSelectInstall when Install button is clicked', async () => {
+ const servers = [{ qualified_name: 'acme/file-server', display_name: 'File Server' }];
+ mockRegistrySearch.mockResolvedValue({ servers, page: 1, total_pages: 1 });
+ const onSelectInstall = vi.fn();
+ render();
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(300);
+ });
+ vi.useRealTimers();
+
+ await waitFor(() => screen.getByText('File Server'));
+
+ fireEvent.click(screen.getByRole('button', { name: 'Install' }));
+ expect(onSelectInstall).toHaveBeenCalledWith('acme/file-server');
+ });
+
+ it('shows load more when more pages available', async () => {
+ const servers = [{ qualified_name: 'a/b', display_name: 'B' }];
+ mockRegistrySearch.mockResolvedValue({ servers, page: 1, total_pages: 3 });
+ render( {}} />);
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(300);
+ });
+ vi.useRealTimers();
+
+ await waitFor(() => screen.getByText('Load more'));
+ expect(screen.getByRole('button', { name: 'Load more' })).toBeInTheDocument();
+ });
+
+ it('shows error state when search fails', async () => {
+ mockRegistrySearch.mockRejectedValue(new Error('Network error'));
+ render( {}} />);
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(300);
+ });
+ vi.useRealTimers();
+
+ await waitFor(() => screen.getByText('Network error'));
+ });
+});
diff --git a/app/src/components/channels/mcp/McpCatalogBrowser.tsx b/app/src/components/channels/mcp/McpCatalogBrowser.tsx
new file mode 100644
index 000000000..de95717bd
--- /dev/null
+++ b/app/src/components/channels/mcp/McpCatalogBrowser.tsx
@@ -0,0 +1,135 @@
+/**
+ * Smithery registry browser with debounced search and pagination.
+ * Clicking "Install" on a card opens the InstallDialog flow.
+ */
+import debug from 'debug';
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+import { mcpClientsApi } from '../../../services/api/mcpClientsApi';
+import SmitheryServerCard from './SmitheryServerCard';
+import type { SmitheryServer } from './types';
+
+const log = debug('mcp-clients:catalog');
+const DEBOUNCE_MS = 250;
+const PAGE_SIZE = 20;
+
+interface McpCatalogBrowserProps {
+ onSelectInstall: (qualifiedName: string) => void;
+}
+
+const McpCatalogBrowser = ({ onSelectInstall }: McpCatalogBrowserProps) => {
+ const [query, setQuery] = useState('');
+ const [servers, setServers] = useState([]);
+ const [page, setPage] = useState(1);
+ const [totalPages, setTotalPages] = useState(1);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const debounceRef = useRef | null>(null);
+ // Monotonically-increasing counter used to discard stale registrySearch
+ // responses when a newer request has already been issued.
+ const requestSeqRef = useRef(0);
+
+ const fetchPage = useCallback(async (searchQuery: string, pageNum: number, append: boolean) => {
+ const seq = ++requestSeqRef.current;
+ setLoading(true);
+ setError(null);
+ log('fetching page=%d query=%s seq=%d', pageNum, searchQuery, seq);
+ try {
+ const result = await mcpClientsApi.registrySearch({
+ query: searchQuery || undefined,
+ page: pageNum,
+ page_size: PAGE_SIZE,
+ });
+ // Discard if a newer request has already been dispatched.
+ if (seq !== requestSeqRef.current) {
+ log('discarding stale response seq=%d (latest=%d)', seq, requestSeqRef.current);
+ return;
+ }
+ setTotalPages(result.total_pages);
+ setPage(result.page);
+ setServers(prev => (append ? [...prev, ...result.servers] : result.servers));
+ log('loaded %d servers (append=%s)', result.servers.length, append);
+ } catch (err) {
+ if (seq !== requestSeqRef.current) return;
+ const msg = err instanceof Error ? err.message : 'Failed to load catalog';
+ log('catalog fetch error: %s', msg);
+ setError(msg);
+ } finally {
+ if (seq === requestSeqRef.current) {
+ setLoading(false);
+ }
+ }
+ }, []);
+
+ // Debounce the query and reset to page 1 whenever it changes.
+ useEffect(() => {
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ debounceRef.current = setTimeout(() => {
+ void fetchPage(query, 1, false);
+ }, DEBOUNCE_MS);
+ return () => {
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ };
+ }, [query, fetchPage]);
+
+ const handleLoadMore = () => {
+ void fetchPage(query, page + 1, true);
+ };
+
+ return (
+
+
+ setQuery(e.target.value)}
+ className="flex-1 rounded-lg border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-800 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:outline-none focus:ring-2 focus:ring-primary-500/40"
+ />
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {loading && servers.length === 0 ? (
+
+ Loading...
+
+ ) : servers.length === 0 ? (
+
+ No servers found{query ? ` for "${query}"` : ''}.
+
+ ) : (
+ <>
+
+ {servers.map(server => (
+
+ ))}
+
+
+ {page < totalPages && (
+
+
+
+ )}
+ >
+ )}
+
+ );
+};
+
+export default McpCatalogBrowser;
diff --git a/app/src/components/channels/mcp/McpServersTab.test.tsx b/app/src/components/channels/mcp/McpServersTab.test.tsx
new file mode 100644
index 000000000..9029e3abc
--- /dev/null
+++ b/app/src/components/channels/mcp/McpServersTab.test.tsx
@@ -0,0 +1,366 @@
+/**
+ * Tests for McpServersTab — the top-level two-pane MCP servers view.
+ * Covers the major flows: initial load, error state, pane transitions,
+ * install success, uninstall, and status polling.
+ */
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import McpServersTab from './McpServersTab';
+
+const mockInstalledList = vi.fn();
+const mockStatus = vi.fn();
+const mockInstall = vi.fn();
+const mockConnect = vi.fn();
+const mockDisconnect = vi.fn();
+const mockUninstall = vi.fn();
+const mockRegistryGet = vi.fn();
+const mockRegistrySearch = vi.fn();
+const mockConfigAssist = vi.fn();
+
+vi.mock('../../../services/api/mcpClientsApi', () => ({
+ mcpClientsApi: {
+ installedList: (...args: unknown[]) => mockInstalledList(...args),
+ status: (...args: unknown[]) => mockStatus(...args),
+ install: (...args: unknown[]) => mockInstall(...args),
+ connect: (...args: unknown[]) => mockConnect(...args),
+ disconnect: (...args: unknown[]) => mockDisconnect(...args),
+ uninstall: (...args: unknown[]) => mockUninstall(...args),
+ registryGet: (...args: unknown[]) => mockRegistryGet(...args),
+ registrySearch: (...args: unknown[]) => mockRegistrySearch(...args),
+ configAssist: (...args: unknown[]) => mockConfigAssist(...args),
+ },
+}));
+
+const SERVERS = [
+ {
+ server_id: 'srv-1',
+ qualified_name: 'acme/fs-server',
+ display_name: 'File Server',
+ description: 'Reads files',
+ command_kind: 'node' as const,
+ command: 'npx',
+ args: ['-y', 'acme/fs-server'],
+ env_keys: [],
+ installed_at: 1_700_000_000,
+ },
+];
+
+const STATUSES_DISCONNECTED = [
+ {
+ server_id: 'srv-1',
+ qualified_name: 'acme/fs-server',
+ display_name: 'File Server',
+ status: 'disconnected' as const,
+ tool_count: 0,
+ },
+];
+
+const STATUSES_CONNECTED = [
+ {
+ server_id: 'srv-1',
+ qualified_name: 'acme/fs-server',
+ display_name: 'File Server',
+ status: 'connected' as const,
+ tool_count: 2,
+ },
+];
+
+describe('McpServersTab', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ mockInstalledList.mockReset();
+ mockStatus.mockReset();
+ mockInstall.mockReset();
+ mockConnect.mockReset();
+ mockDisconnect.mockReset();
+ mockUninstall.mockReset();
+ mockRegistryGet.mockReset();
+ mockRegistrySearch.mockReset();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('shows loading state on initial render', () => {
+ mockInstalledList.mockReturnValue(new Promise(() => {}));
+ mockStatus.mockReturnValue(new Promise(() => {}));
+ render();
+ expect(screen.getByText('Loading MCP servers...')).toBeInTheDocument();
+ });
+
+ it('renders installed server list after load', async () => {
+ mockInstalledList.mockResolvedValue(SERVERS);
+ mockStatus.mockResolvedValue(STATUSES_DISCONNECTED);
+
+ render();
+ vi.useRealTimers();
+
+ await waitFor(() => {
+ expect(screen.getByText('File Server')).toBeInTheDocument();
+ });
+ expect(screen.getByText('Browse catalog')).toBeInTheDocument();
+ });
+
+ it('shows empty state when no servers installed', async () => {
+ mockInstalledList.mockResolvedValue([]);
+ mockStatus.mockResolvedValue([]);
+
+ render();
+ vi.useRealTimers();
+
+ await waitFor(() => {
+ expect(screen.getByText('No MCP servers installed yet.')).toBeInTheDocument();
+ });
+ });
+
+ it('shows load error when installedList fails', async () => {
+ mockInstalledList.mockRejectedValue(new Error('DB error'));
+ mockStatus.mockResolvedValue([]);
+
+ render();
+ vi.useRealTimers();
+
+ await waitFor(() => {
+ expect(screen.getByText('DB error')).toBeInTheDocument();
+ });
+ });
+
+ it('shows placeholder in right pane when no server selected', async () => {
+ mockInstalledList.mockResolvedValue(SERVERS);
+ mockStatus.mockResolvedValue(STATUSES_DISCONNECTED);
+
+ render();
+ vi.useRealTimers();
+
+ await waitFor(() => screen.getByText('File Server'));
+ expect(screen.getByText('Select a server or browse the catalog.')).toBeInTheDocument();
+ });
+
+ it('opens detail pane when a server is clicked', async () => {
+ mockInstalledList.mockResolvedValue(SERVERS);
+ mockStatus.mockResolvedValue(STATUSES_DISCONNECTED);
+
+ render();
+ vi.useRealTimers();
+
+ await waitFor(() => screen.getByText('File Server'));
+ fireEvent.click(screen.getAllByRole('button', { name: /File Server/i })[0]);
+
+ await waitFor(() => {
+ expect(screen.getByText('acme/fs-server')).toBeInTheDocument();
+ });
+ });
+
+ it('opens catalog browser when Browse catalog is clicked', async () => {
+ mockInstalledList.mockResolvedValue([]);
+ mockStatus.mockResolvedValue([]);
+ mockRegistrySearch.mockResolvedValue({ servers: [], page: 1, total_pages: 1 });
+
+ render();
+ vi.useRealTimers();
+
+ await waitFor(() => screen.getByText('No MCP servers installed yet.'));
+
+ // advance past debounce in McpCatalogBrowser
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync?.(300).catch(() => {});
+ });
+
+ fireEvent.click(screen.getAllByRole('button', { name: 'Browse catalog' })[0]);
+
+ await waitFor(() => {
+ expect(screen.getByPlaceholderText('Search Smithery catalog...')).toBeInTheDocument();
+ });
+ });
+
+ it('clears load error on successful reload after failure', async () => {
+ // Initial load fails (transient error banner appears), then a successful
+ // install triggers loadInstalled() — the banner must be cleared on success.
+ mockInstalledList.mockRejectedValueOnce(new Error('Transient error'));
+ mockStatus.mockResolvedValue([]);
+ mockRegistrySearch.mockResolvedValue({
+ servers: [{ qualified_name: 'acme/new-srv', display_name: 'New Server' }],
+ page: 1,
+ total_pages: 1,
+ });
+
+ render();
+ vi.useRealTimers();
+
+ await waitFor(() => screen.getByText('Transient error'));
+
+ // Drive an install flow that triggers loadInstalled() on success.
+ const detail = {
+ qualified_name: 'acme/new-srv',
+ display_name: 'New Server',
+ description: null,
+ connections: [],
+ required_env_keys: [],
+ };
+ const newServer = { ...SERVERS[0], server_id: 'srv-new', qualified_name: 'acme/new-srv' };
+ mockRegistryGet.mockResolvedValue(detail);
+ mockInstall.mockResolvedValue(newServer);
+ mockInstalledList.mockResolvedValue([newServer]); // reload after install succeeds
+
+ fireEvent.click(screen.getAllByRole('button', { name: 'Browse catalog' })[0]);
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync?.(300).catch(() => {});
+ });
+
+ await waitFor(() => screen.getByText('New Server'));
+ fireEvent.click(screen.getByRole('button', { name: 'Install' }));
+ await waitFor(() => screen.getByRole('button', { name: 'Install' }));
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'Install' }));
+ });
+
+ // After successful reload, the error banner must be gone.
+ await waitFor(() => {
+ expect(screen.queryByText('Transient error')).not.toBeInTheDocument();
+ });
+ });
+
+ it('shows tool count badge when connected', async () => {
+ mockInstalledList.mockResolvedValue(SERVERS);
+ mockStatus.mockResolvedValue(STATUSES_CONNECTED);
+
+ render();
+ vi.useRealTimers();
+
+ await waitFor(() => {
+ expect(screen.getByText('2 tools')).toBeInTheDocument();
+ });
+ });
+
+ it('opens install dialog from catalog browser', async () => {
+ mockInstalledList.mockResolvedValue([]);
+ mockStatus.mockResolvedValue([]);
+ mockRegistrySearch.mockResolvedValue({
+ servers: [{ qualified_name: 'acme/new-srv', display_name: 'New Server' }],
+ page: 1,
+ total_pages: 1,
+ });
+ mockRegistryGet.mockReturnValue(new Promise(() => {}));
+
+ render();
+ vi.useRealTimers();
+
+ await waitFor(() => screen.getByText('No MCP servers installed yet.'));
+ fireEvent.click(screen.getAllByRole('button', { name: 'Browse catalog' })[0]);
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync?.(300).catch(() => {});
+ });
+
+ await waitFor(() => screen.getByText('New Server'));
+ fireEvent.click(screen.getByRole('button', { name: 'Install' }));
+
+ await waitFor(() => {
+ expect(screen.getByText('Loading server details...')).toBeInTheDocument();
+ });
+ });
+
+ it('returns to catalog after install cancel', async () => {
+ mockInstalledList.mockResolvedValue([]);
+ mockStatus.mockResolvedValue([]);
+ mockRegistrySearch.mockResolvedValue({
+ servers: [{ qualified_name: 'acme/new-srv', display_name: 'New Server' }],
+ page: 1,
+ total_pages: 1,
+ });
+ const detail = {
+ qualified_name: 'acme/new-srv',
+ display_name: 'New Server',
+ description: null,
+ connections: [],
+ required_env_keys: [],
+ };
+ mockRegistryGet.mockResolvedValue(detail);
+
+ render();
+ vi.useRealTimers();
+
+ await waitFor(() => screen.getByText('No MCP servers installed yet.'));
+ fireEvent.click(screen.getAllByRole('button', { name: 'Browse catalog' })[0]);
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync?.(300).catch(() => {});
+ });
+
+ await waitFor(() => screen.getByText('New Server'));
+ fireEvent.click(screen.getByRole('button', { name: 'Install' }));
+ await waitFor(() => screen.getByRole('button', { name: 'Cancel' }));
+
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
+
+ await waitFor(() => {
+ expect(screen.getByPlaceholderText('Search Smithery catalog...')).toBeInTheDocument();
+ });
+ });
+
+ it('refreshes list and shows detail after install success', async () => {
+ mockInstalledList.mockResolvedValue([]);
+ mockStatus.mockResolvedValue([]);
+ mockRegistrySearch.mockResolvedValue({
+ servers: [{ qualified_name: 'acme/new-srv', display_name: 'New Server' }],
+ page: 1,
+ total_pages: 1,
+ });
+ const detail = {
+ qualified_name: 'acme/new-srv',
+ display_name: 'New Server',
+ description: null,
+ connections: [],
+ required_env_keys: [],
+ };
+ const newServer = {
+ server_id: 'srv-new',
+ qualified_name: 'acme/new-srv',
+ display_name: 'New Server',
+ description: null,
+ command_kind: 'node' as const,
+ command: 'npx',
+ args: ['-y', 'acme/new-srv'],
+ env_keys: [],
+ installed_at: 1_700_000_001,
+ };
+ mockRegistryGet.mockResolvedValue(detail);
+ mockInstall.mockResolvedValue(newServer);
+ // After install, list refresh returns new server
+ mockInstalledList.mockResolvedValueOnce([]).mockResolvedValue([newServer]);
+ mockStatus.mockResolvedValue([
+ {
+ server_id: 'srv-new',
+ qualified_name: 'acme/new-srv',
+ display_name: 'New Server',
+ status: 'disconnected',
+ tool_count: 0,
+ },
+ ]);
+
+ render();
+ vi.useRealTimers();
+
+ await waitFor(() => screen.getByText('No MCP servers installed yet.'));
+ fireEvent.click(screen.getAllByRole('button', { name: 'Browse catalog' })[0]);
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync?.(300).catch(() => {});
+ });
+
+ await waitFor(() => screen.getByText('New Server'));
+ fireEvent.click(screen.getByRole('button', { name: 'Install' }));
+
+ await waitFor(() => screen.getByRole('button', { name: 'Install' }));
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'Install' }));
+ });
+
+ await waitFor(() => {
+ expect(mockInstall).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/app/src/components/channels/mcp/McpServersTab.tsx b/app/src/components/channels/mcp/McpServersTab.tsx
new file mode 100644
index 000000000..1799847af
--- /dev/null
+++ b/app/src/components/channels/mcp/McpServersTab.tsx
@@ -0,0 +1,190 @@
+/**
+ * Top-level MCP Servers tab component.
+ * Two-pane layout: left = InstalledServerList + browse button,
+ * right = selected server detail OR catalog browser OR install dialog.
+ * Polls `status` every 5s while any server is connected.
+ */
+import debug from 'debug';
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+import { mcpClientsApi } from '../../../services/api/mcpClientsApi';
+import InstallDialog from './InstallDialog';
+import InstalledServerDetail from './InstalledServerDetail';
+import InstalledServerList from './InstalledServerList';
+import McpCatalogBrowser from './McpCatalogBrowser';
+import type { ConnStatus, InstalledServer } from './types';
+
+const log = debug('mcp-clients:tab');
+const POLL_INTERVAL_MS = 5_000;
+
+type RightPane =
+ | { mode: 'none' }
+ | { mode: 'detail'; serverId: string }
+ | { mode: 'catalog' }
+ | { mode: 'install'; qualifiedName: string; prefillEnv?: Record };
+
+const McpServersTab = () => {
+ const [servers, setServers] = useState([]);
+ const [statuses, setStatuses] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [loadError, setLoadError] = useState(null);
+ const [rightPane, setRightPane] = useState({ mode: 'none' });
+ const pollTimerRef = useRef | null>(null);
+
+ const loadInstalled = useCallback(async () => {
+ log('loading installed servers');
+ try {
+ const installed = await mcpClientsApi.installedList();
+ setServers(installed);
+ // Clear any previous error on successful reload.
+ setLoadError(null);
+ log('loaded %d installed servers', installed.length);
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : 'Failed to load installed servers';
+ log('load error: %s', msg);
+ setLoadError(msg);
+ }
+ }, []);
+
+ const fetchStatuses = useCallback(async () => {
+ log('polling statuses');
+ try {
+ const sv = await mcpClientsApi.status();
+ setStatuses(sv);
+ } catch (err) {
+ log('status poll error: %o', err);
+ }
+ }, []);
+
+ // Initial load — `loading` starts as `true` so no synchronous setState
+ // before the async work is needed; just kick off the loads and clear on done.
+ useEffect(() => {
+ Promise.all([loadInstalled(), fetchStatuses()]).finally(() => setLoading(false));
+ }, [loadInstalled, fetchStatuses]);
+
+ // Poll status every 5s while at least one server is connected.
+ useEffect(() => {
+ const hasConnected = statuses.some(s => s.status === 'connected');
+ if (!hasConnected) {
+ if (pollTimerRef.current) {
+ clearTimeout(pollTimerRef.current);
+ pollTimerRef.current = null;
+ }
+ return;
+ }
+
+ const schedule = () => {
+ pollTimerRef.current = setTimeout(async () => {
+ await fetchStatuses();
+ schedule();
+ }, POLL_INTERVAL_MS);
+ };
+ schedule();
+
+ return () => {
+ if (pollTimerRef.current) {
+ clearTimeout(pollTimerRef.current);
+ pollTimerRef.current = null;
+ }
+ };
+ }, [statuses, fetchStatuses]);
+
+ const handleSelectServer = useCallback((serverId: string) => {
+ log('selected server_id=%s', serverId);
+ setRightPane({ mode: 'detail', serverId });
+ }, []);
+
+ const handleBrowseCatalog = useCallback(() => {
+ log('opening catalog browser');
+ setRightPane({ mode: 'catalog' });
+ }, []);
+
+ const handleSelectInstall = useCallback((qualifiedName: string) => {
+ log('opening install dialog for %s', qualifiedName);
+ setRightPane({ mode: 'install', qualifiedName });
+ }, []);
+
+ const handleInstallSuccess = useCallback(
+ async (server: InstalledServer) => {
+ log('install success server_id=%s, refreshing list', server.server_id);
+ await loadInstalled();
+ await fetchStatuses();
+ setRightPane({ mode: 'detail', serverId: server.server_id });
+ },
+ [loadInstalled, fetchStatuses]
+ );
+
+ const handleUninstalled = useCallback(
+ async (serverId: string) => {
+ log('uninstalled server_id=%s', serverId);
+ await loadInstalled();
+ await fetchStatuses();
+ setRightPane({ mode: 'none' });
+ },
+ [loadInstalled, fetchStatuses]
+ );
+
+ const selectedServerId = rightPane.mode === 'detail' ? rightPane.serverId : null;
+ const selectedServer = servers.find(s => s.server_id === selectedServerId) ?? null;
+ const selectedConnStatus = statuses.find(s => s.server_id === selectedServerId);
+
+ if (loading) {
+ return (
+
+ Loading MCP servers...
+
+ );
+ }
+
+ return (
+
+ {/* Left pane: installed list */}
+
+ {loadError && (
+
+ {loadError}
+
+ )}
+
+
+
+ {/* Right pane */}
+
+ {rightPane.mode === 'none' && (
+
+ Select a server or browse the catalog.
+
+ )}
+
+ {rightPane.mode === 'catalog' && (
+
+ )}
+
+ {rightPane.mode === 'install' && (
+
void handleInstallSuccess(server)}
+ onCancel={() => setRightPane({ mode: 'catalog' })}
+ />
+ )}
+
+ {rightPane.mode === 'detail' && selectedServer && (
+ void handleUninstalled(serverId)}
+ />
+ )}
+
+
+ );
+};
+
+export default McpServersTab;
diff --git a/app/src/components/channels/mcp/McpStatusBadge.tsx b/app/src/components/channels/mcp/McpStatusBadge.tsx
new file mode 100644
index 000000000..586fb8fc1
--- /dev/null
+++ b/app/src/components/channels/mcp/McpStatusBadge.tsx
@@ -0,0 +1,42 @@
+/**
+ * Status badge for MCP server connection states.
+ * Mirrors ChannelStatusBadge but uses ServerStatus values.
+ */
+import type { ServerStatus } from './types';
+
+const STATUS_STYLES: Record = {
+ connected: {
+ label: 'Connected',
+ className: 'bg-sage-500/10 text-sage-700 border-sage-500/30 dark:text-sage-300',
+ },
+ connecting: {
+ label: 'Connecting',
+ className: 'bg-amber-500/10 text-amber-700 border-amber-500/30 dark:text-amber-300',
+ },
+ disconnected: {
+ label: 'Disconnected',
+ className:
+ 'bg-stone-100 dark:bg-neutral-800 text-stone-500 dark:text-neutral-400 border-stone-200 dark:border-neutral-700',
+ },
+ error: {
+ label: 'Error',
+ className: 'bg-coral-500/10 text-coral-700 border-coral-500/30 dark:text-coral-300',
+ },
+};
+
+interface McpStatusBadgeProps {
+ status: ServerStatus;
+ className?: string;
+}
+
+const McpStatusBadge = ({ status, className = '' }: McpStatusBadgeProps) => {
+ const style = STATUS_STYLES[status] ?? STATUS_STYLES.disconnected;
+ return (
+
+ {style.label}
+
+ );
+};
+
+export default McpStatusBadge;
diff --git a/app/src/components/channels/mcp/McpToolList.test.tsx b/app/src/components/channels/mcp/McpToolList.test.tsx
new file mode 100644
index 000000000..93e226169
--- /dev/null
+++ b/app/src/components/channels/mcp/McpToolList.test.tsx
@@ -0,0 +1,78 @@
+/**
+ * Tests for McpToolList — collapsible tool list.
+ */
+import { fireEvent, render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+
+import McpToolList from './McpToolList';
+import type { McpTool } from './types';
+
+const TOOLS: McpTool[] = [
+ { name: 'read_file', description: 'Reads a file from disk', input_schema: {} },
+ { name: 'write_file', description: 'Writes data to a file', input_schema: {} },
+ { name: 'list_dir', description: undefined, input_schema: {} },
+];
+
+describe('McpToolList', () => {
+ it('shows empty state when no tools', () => {
+ render();
+ expect(screen.getByText('No tools available.')).toBeInTheDocument();
+ });
+
+ it('shows collapsed state with correct tool count', () => {
+ render();
+ expect(screen.getByText('3 tools available')).toBeInTheDocument();
+ // Tool names are not visible until expanded
+ expect(screen.queryByText('read_file')).not.toBeInTheDocument();
+ });
+
+ it('shows singular "tool" for a single tool', () => {
+ render();
+ expect(screen.getByText('1 tool available')).toBeInTheDocument();
+ });
+
+ it('expands tool list when toggle button is clicked', () => {
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /tools available/i }));
+ expect(screen.getByText('read_file')).toBeInTheDocument();
+ expect(screen.getByText('write_file')).toBeInTheDocument();
+ expect(screen.getByText('list_dir')).toBeInTheDocument();
+ });
+
+ it('shows tool descriptions when expanded', () => {
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /tools available/i }));
+ expect(screen.getByText('Reads a file from disk')).toBeInTheDocument();
+ expect(screen.getByText('Writes data to a file')).toBeInTheDocument();
+ });
+
+ it('does not render description paragraph when description is undefined', () => {
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /tools available/i }));
+ // list_dir has no description — only 2 description paragraphs should exist
+ const descriptions = screen
+ .getAllByRole('listitem')
+ .filter(item => item.querySelector('p + p'));
+ // We expect 2 of the 3 items to have a description paragraph
+ expect(screen.getByText('Reads a file from disk')).toBeInTheDocument();
+ expect(screen.queryByText('undefined')).not.toBeInTheDocument();
+ expect(descriptions).toHaveLength(2);
+ });
+
+ it('collapses again when toggle button is clicked twice', () => {
+ render();
+ const btn = screen.getByRole('button', { name: /tools available/i });
+ fireEvent.click(btn);
+ expect(screen.getByText('read_file')).toBeInTheDocument();
+ fireEvent.click(btn);
+ expect(screen.queryByText('read_file')).not.toBeInTheDocument();
+ });
+
+ it('arrow rotates when expanded', () => {
+ render();
+ const arrow = screen.getByText('▶');
+ expect(arrow.className).not.toMatch(/rotate-90/);
+ fireEvent.click(screen.getByRole('button', { name: /tools available/i }));
+ expect(arrow.className).toMatch(/rotate-90/);
+ });
+});
diff --git a/app/src/components/channels/mcp/McpToolList.tsx b/app/src/components/channels/mcp/McpToolList.tsx
new file mode 100644
index 000000000..4992a2ff4
--- /dev/null
+++ b/app/src/components/channels/mcp/McpToolList.tsx
@@ -0,0 +1,51 @@
+/**
+ * Collapsible list of MCP tools with name and description.
+ */
+import { useState } from 'react';
+
+import type { McpTool } from './types';
+
+interface McpToolListProps {
+ tools: McpTool[];
+}
+
+const McpToolList = ({ tools }: McpToolListProps) => {
+ const [expanded, setExpanded] = useState(false);
+
+ if (tools.length === 0) {
+ return No tools available.
;
+ }
+
+ return (
+
+
+
+ {expanded && (
+
+ {tools.map(tool => (
+ -
+
+ {tool.name}
+
+ {tool.description && (
+
+ {tool.description}
+
+ )}
+
+ ))}
+
+ )}
+
+ );
+};
+
+export default McpToolList;
diff --git a/app/src/components/channels/mcp/SmitheryServerCard.tsx b/app/src/components/channels/mcp/SmitheryServerCard.tsx
new file mode 100644
index 000000000..b4efe4df3
--- /dev/null
+++ b/app/src/components/channels/mcp/SmitheryServerCard.tsx
@@ -0,0 +1,62 @@
+/**
+ * Card component for a single Smithery registry server.
+ * Shows icon, name, description (clamped), usage count and deployed badge.
+ */
+import type { SmitheryServer } from './types';
+
+interface SmitheryServerCardProps {
+ server: SmitheryServer;
+ onInstall: (qualifiedName: string) => void;
+}
+
+const SmitheryServerCard = ({ server, onInstall }: SmitheryServerCardProps) => {
+ return (
+
+
+ {server.icon_url ? (
+

+ ) : (
+
+ 🔌
+
+ )}
+
+
+
+ {server.display_name}
+
+ {server.is_deployed && (
+
+ Deployed
+
+ )}
+
+ {server.use_count != null && server.use_count > 0 && (
+
+ {server.use_count.toLocaleString()} installs
+
+ )}
+
+
+
+ {server.description && (
+
+ {server.description}
+
+ )}
+
+
+
+ );
+};
+
+export default SmitheryServerCard;
diff --git a/app/src/components/channels/mcp/types.ts b/app/src/components/channels/mcp/types.ts
new file mode 100644
index 000000000..45bba8894
--- /dev/null
+++ b/app/src/components/channels/mcp/types.ts
@@ -0,0 +1,56 @@
+/**
+ * Shared TypeScript types for the MCP Servers tab.
+ * Single source of truth — import from here, not from the API layer.
+ */
+
+export type SmitheryServer = {
+ qualified_name: string;
+ display_name: string;
+ description?: string;
+ icon_url?: string;
+ use_count?: number;
+ is_deployed?: boolean;
+};
+
+export type SmitheryConnection = {
+ type: 'stdio' | 'http';
+ deployment_url?: string;
+ config_schema?: unknown;
+ example_config?: unknown;
+ published?: boolean;
+};
+
+export type SmitheryServerDetail = SmitheryServer & {
+ connections: SmitheryConnection[];
+ required_env_keys?: string[];
+};
+
+export type CommandKind = 'node' | 'python' | 'binary';
+
+export type InstalledServer = {
+ server_id: string;
+ qualified_name: string;
+ display_name: string;
+ description?: string;
+ icon_url?: string;
+ command_kind: CommandKind;
+ command: string;
+ args: string[];
+ env_keys: string[];
+ config?: unknown;
+ installed_at: number;
+ last_connected_at?: number;
+};
+
+export type McpTool = { name: string; description?: string; input_schema: unknown };
+
+export type ServerStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
+
+export type ConnStatus = {
+ server_id: string;
+ qualified_name: string;
+ display_name: string;
+ status: ServerStatus;
+ tool_count: number;
+ last_error?: string;
+};
diff --git a/app/src/services/api/mcpClientsApi.test.ts b/app/src/services/api/mcpClientsApi.test.ts
new file mode 100644
index 000000000..a68ce8596
--- /dev/null
+++ b/app/src/services/api/mcpClientsApi.test.ts
@@ -0,0 +1,236 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const mockCallCoreRpc = vi.fn();
+
+vi.mock('../coreRpcClient', () => ({
+ callCoreRpc: (...args: unknown[]) => mockCallCoreRpc(...args),
+}));
+
+describe('mcpClientsApi', () => {
+ beforeEach(() => {
+ mockCallCoreRpc.mockReset();
+ });
+
+ describe('registrySearch', () => {
+ it('calls the correct method and returns servers', async () => {
+ const servers = [{ qualified_name: 'test/server', display_name: 'Test' }];
+ mockCallCoreRpc.mockResolvedValueOnce({ servers, page: 1, total_pages: 3 });
+
+ const { mcpClientsApi } = await import('./mcpClientsApi');
+ const result = await mcpClientsApi.registrySearch({ query: 'test', page: 1 });
+
+ expect(mockCallCoreRpc).toHaveBeenCalledWith({
+ method: 'openhuman.mcp_clients_registry_search',
+ params: { query: 'test', page: 1 },
+ });
+ expect(result.servers).toEqual(servers);
+ expect(result.page).toBe(1);
+ expect(result.total_pages).toBe(3);
+ });
+
+ it('omits undefined query', async () => {
+ mockCallCoreRpc.mockResolvedValueOnce({ servers: [], page: 1, total_pages: 1 });
+
+ const { mcpClientsApi } = await import('./mcpClientsApi');
+ await mcpClientsApi.registrySearch({});
+
+ expect(mockCallCoreRpc).toHaveBeenCalledWith({
+ method: 'openhuman.mcp_clients_registry_search',
+ params: {},
+ });
+ });
+ });
+
+ describe('registryGet', () => {
+ it('calls registry_get and unwraps server', async () => {
+ const serverDetail = {
+ qualified_name: 'test/server',
+ display_name: 'Test Server',
+ connections: [],
+ required_env_keys: ['API_KEY'],
+ };
+ mockCallCoreRpc.mockResolvedValueOnce({ server: serverDetail });
+
+ const { mcpClientsApi } = await import('./mcpClientsApi');
+ const result = await mcpClientsApi.registryGet('test/server');
+
+ expect(mockCallCoreRpc).toHaveBeenCalledWith({
+ method: 'openhuman.mcp_clients_registry_get',
+ params: { qualified_name: 'test/server' },
+ });
+ expect(result).toEqual(serverDetail);
+ });
+ });
+
+ describe('installedList', () => {
+ it('calls installed_list and returns the installed array', async () => {
+ const installed = [
+ {
+ server_id: 'srv-1',
+ qualified_name: 'test/server',
+ display_name: 'Test',
+ command_kind: 'node',
+ command: 'node',
+ args: [],
+ env_keys: ['API_KEY'],
+ installed_at: 1_700_000_000,
+ },
+ ];
+ mockCallCoreRpc.mockResolvedValueOnce({ installed });
+
+ const { mcpClientsApi } = await import('./mcpClientsApi');
+ const result = await mcpClientsApi.installedList();
+
+ expect(mockCallCoreRpc).toHaveBeenCalledWith({
+ method: 'openhuman.mcp_clients_installed_list',
+ params: {},
+ });
+ expect(result).toEqual(installed);
+ });
+ });
+
+ describe('install', () => {
+ it('calls install with correct params and returns server', async () => {
+ const server = {
+ server_id: 'srv-1',
+ qualified_name: 'test/server',
+ display_name: 'Test',
+ command_kind: 'node',
+ command: 'node',
+ args: [],
+ env_keys: ['API_KEY'],
+ installed_at: 1_700_000_000,
+ };
+ mockCallCoreRpc.mockResolvedValueOnce({ server });
+
+ const { mcpClientsApi } = await import('./mcpClientsApi');
+ const result = await mcpClientsApi.install({
+ qualified_name: 'test/server',
+ env: { API_KEY: 'secret' },
+ });
+
+ expect(mockCallCoreRpc).toHaveBeenCalledWith({
+ method: 'openhuman.mcp_clients_install',
+ params: { qualified_name: 'test/server', env: { API_KEY: 'secret' } },
+ });
+ expect(result).toEqual(server);
+ });
+ });
+
+ describe('uninstall', () => {
+ it('calls uninstall and returns the result', async () => {
+ mockCallCoreRpc.mockResolvedValueOnce({ server_id: 'srv-1', removed: true });
+
+ const { mcpClientsApi } = await import('./mcpClientsApi');
+ const result = await mcpClientsApi.uninstall('srv-1');
+
+ expect(mockCallCoreRpc).toHaveBeenCalledWith({
+ method: 'openhuman.mcp_clients_uninstall',
+ params: { server_id: 'srv-1' },
+ });
+ expect(result.removed).toBe(true);
+ });
+ });
+
+ describe('connect', () => {
+ it('calls connect and returns status + tools', async () => {
+ const tools = [{ name: 'readFile', input_schema: {} }];
+ mockCallCoreRpc.mockResolvedValueOnce({ server_id: 'srv-1', status: 'connected', tools });
+
+ const { mcpClientsApi } = await import('./mcpClientsApi');
+ const result = await mcpClientsApi.connect('srv-1');
+
+ expect(mockCallCoreRpc).toHaveBeenCalledWith({
+ method: 'openhuman.mcp_clients_connect',
+ params: { server_id: 'srv-1' },
+ });
+ expect(result.status).toBe('connected');
+ expect(result.tools).toEqual(tools);
+ });
+ });
+
+ describe('disconnect', () => {
+ it('calls disconnect and returns status', async () => {
+ mockCallCoreRpc.mockResolvedValueOnce({ server_id: 'srv-1', status: 'disconnected' });
+
+ const { mcpClientsApi } = await import('./mcpClientsApi');
+ const result = await mcpClientsApi.disconnect('srv-1');
+
+ expect(mockCallCoreRpc).toHaveBeenCalledWith({
+ method: 'openhuman.mcp_clients_disconnect',
+ params: { server_id: 'srv-1' },
+ });
+ expect(result.status).toBe('disconnected');
+ });
+ });
+
+ describe('status', () => {
+ it('calls status and returns servers array', async () => {
+ const servers = [
+ {
+ server_id: 'srv-1',
+ qualified_name: 'q',
+ display_name: 'd',
+ status: 'connected',
+ tool_count: 3,
+ },
+ ];
+ mockCallCoreRpc.mockResolvedValueOnce({ servers });
+
+ const { mcpClientsApi } = await import('./mcpClientsApi');
+ const result = await mcpClientsApi.status();
+
+ expect(mockCallCoreRpc).toHaveBeenCalledWith({
+ method: 'openhuman.mcp_clients_status',
+ params: {},
+ });
+ expect(result).toEqual(servers);
+ });
+ });
+
+ describe('toolCall', () => {
+ it('calls tool_call and returns result', async () => {
+ mockCallCoreRpc.mockResolvedValueOnce({ result: 'file contents', is_error: false });
+
+ const { mcpClientsApi } = await import('./mcpClientsApi');
+ const result = await mcpClientsApi.toolCall({
+ server_id: 'srv-1',
+ tool_name: 'readFile',
+ arguments: { path: '/etc/hosts' },
+ });
+
+ expect(mockCallCoreRpc).toHaveBeenCalledWith({
+ method: 'openhuman.mcp_clients_tool_call',
+ params: { server_id: 'srv-1', tool_name: 'readFile', arguments: { path: '/etc/hosts' } },
+ });
+ expect(result.is_error).toBe(false);
+ });
+ });
+
+ describe('configAssist', () => {
+ it('calls config_assist and returns reply', async () => {
+ mockCallCoreRpc.mockResolvedValueOnce({
+ reply: 'Set API_KEY to your token',
+ suggested_env: { API_KEY: 'token-value' },
+ });
+
+ const { mcpClientsApi } = await import('./mcpClientsApi');
+ const result = await mcpClientsApi.configAssist({
+ qualified_name: 'test/server',
+ user_message: 'How do I configure this?',
+ history: [],
+ });
+
+ expect(mockCallCoreRpc).toHaveBeenCalledWith({
+ method: 'openhuman.mcp_clients_config_assist',
+ params: {
+ qualified_name: 'test/server',
+ user_message: 'How do I configure this?',
+ history: [],
+ },
+ });
+ expect(result.reply).toBe('Set API_KEY to your token');
+ expect(result.suggested_env).toEqual({ API_KEY: 'token-value' });
+ });
+ });
+});
diff --git a/app/src/services/api/mcpClientsApi.ts b/app/src/services/api/mcpClientsApi.ts
new file mode 100644
index 000000000..686242e93
--- /dev/null
+++ b/app/src/services/api/mcpClientsApi.ts
@@ -0,0 +1,207 @@
+/**
+ * Typed RPC wrapper for the MCP Clients domain.
+ * All methods call `openhuman.mcp_clients_` and unwrap the
+ * `{ result: T }` envelope returned by the core RPC framework.
+ *
+ * Centralises method-name strings so components never spell them out directly.
+ */
+import debug from 'debug';
+
+import type {
+ ConnStatus,
+ InstalledServer,
+ McpTool,
+ SmitheryServer,
+ SmitheryServerDetail,
+} from '../../components/channels/mcp/types';
+import { callCoreRpc } from '../coreRpcClient';
+
+const log = debug('mcp-clients:api');
+
+// ---------------------------------------------------------------------------
+// Response envelopes
+// ---------------------------------------------------------------------------
+
+interface RegistrySearchResult {
+ servers: SmitheryServer[];
+ page: number;
+ total_pages: number;
+}
+
+interface RegistryGetResult {
+ server: SmitheryServerDetail;
+}
+
+interface InstalledListResult {
+ installed: InstalledServer[];
+}
+
+interface InstallResult {
+ server: InstalledServer;
+}
+
+interface UninstallResult {
+ server_id: string;
+ removed: boolean;
+}
+
+interface ConnectResult {
+ server_id: string;
+ status: 'connected';
+ tools: McpTool[];
+}
+
+interface DisconnectResult {
+ server_id: string;
+ status: 'disconnected';
+}
+
+interface StatusResult {
+ servers: ConnStatus[];
+}
+
+interface ToolCallResult {
+ result: unknown;
+ is_error: boolean;
+}
+
+interface ConfigAssistResult {
+ reply: string;
+ suggested_env?: Record;
+}
+
+// ---------------------------------------------------------------------------
+// API
+// ---------------------------------------------------------------------------
+
+export const mcpClientsApi = {
+ /** Search the Smithery registry. Returns paged results. */
+ registrySearch: async (params: {
+ query?: string;
+ page?: number;
+ page_size?: number;
+ }): Promise => {
+ log('registry_search params=%o', params);
+ const result = await callCoreRpc({
+ method: 'openhuman.mcp_clients_registry_search',
+ params,
+ });
+ log('registry_search result: %d servers', result.servers?.length ?? 0);
+ return result;
+ },
+
+ /** Fetch full detail for a single Smithery server. */
+ registryGet: async (qualified_name: string): Promise => {
+ log('registry_get qualified_name=%s', qualified_name);
+ const result = await callCoreRpc({
+ method: 'openhuman.mcp_clients_registry_get',
+ params: { qualified_name },
+ });
+ log('registry_get returned server=%s', result.server?.qualified_name);
+ return result.server;
+ },
+
+ /** List all locally installed MCP servers. */
+ installedList: async (): Promise => {
+ log('installed_list');
+ const result = await callCoreRpc({
+ method: 'openhuman.mcp_clients_installed_list',
+ params: {},
+ });
+ log('installed_list returned %d servers', result.installed?.length ?? 0);
+ return result.installed;
+ },
+
+ /** Install a server with the given env vars and optional config. */
+ install: async (params: {
+ qualified_name: string;
+ env: Record;
+ config?: unknown;
+ }): Promise => {
+ log('install qualified_name=%s', params.qualified_name);
+ const result = await callCoreRpc({
+ method: 'openhuman.mcp_clients_install',
+ params,
+ });
+ log('install returned server_id=%s', result.server?.server_id);
+ return result.server;
+ },
+
+ /** Uninstall a server by ID. */
+ uninstall: async (server_id: string): Promise => {
+ log('uninstall server_id=%s', server_id);
+ const result = await callCoreRpc({
+ method: 'openhuman.mcp_clients_uninstall',
+ params: { server_id },
+ });
+ log('uninstall removed=%s', result.removed);
+ return result;
+ },
+
+ /** Connect a server and retrieve its available tools. */
+ connect: async (server_id: string): Promise => {
+ log('connect server_id=%s', server_id);
+ const result = await callCoreRpc({
+ method: 'openhuman.mcp_clients_connect',
+ params: { server_id },
+ });
+ log('connect status=%s tools=%d', result.status, result.tools?.length ?? 0);
+ return result;
+ },
+
+ /** Disconnect a server. */
+ disconnect: async (server_id: string): Promise => {
+ log('disconnect server_id=%s', server_id);
+ const result = await callCoreRpc({
+ method: 'openhuman.mcp_clients_disconnect',
+ params: { server_id },
+ });
+ log('disconnect status=%s', result.status);
+ return result;
+ },
+
+ /** Get status for all managed MCP servers. */
+ status: async (): Promise => {
+ log('status');
+ const result = await callCoreRpc({
+ method: 'openhuman.mcp_clients_status',
+ params: {},
+ });
+ log('status returned %d servers', result.servers?.length ?? 0);
+ return result.servers;
+ },
+
+ /** Invoke a tool on a connected server. */
+ toolCall: async (params: {
+ server_id: string;
+ tool_name: string;
+ arguments: unknown;
+ }): Promise => {
+ log('tool_call server_id=%s tool=%s', params.server_id, params.tool_name);
+ const result = await callCoreRpc({
+ method: 'openhuman.mcp_clients_tool_call',
+ params,
+ });
+ log('tool_call is_error=%s', result.is_error);
+ return result;
+ },
+
+ /** Call the LLM-driven configuration assistant. */
+ configAssist: async (params: {
+ qualified_name: string;
+ user_message: string;
+ history?: { role: 'user' | 'assistant'; content: string }[];
+ }): Promise => {
+ log('config_assist qualified_name=%s', params.qualified_name);
+ const result = await callCoreRpc({
+ method: 'openhuman.mcp_clients_config_assist',
+ params,
+ });
+ log(
+ 'config_assist reply length=%d suggested_env=%s',
+ result.reply?.length ?? 0,
+ result.suggested_env ? 'yes' : 'no'
+ );
+ return result;
+ },
+};
diff --git a/app/src/store/channelConnectionsSlice.ts b/app/src/store/channelConnectionsSlice.ts
index 064d7ea97..faee8db9c 100644
--- a/app/src/store/channelConnectionsSlice.ts
+++ b/app/src/store/channelConnectionsSlice.ts
@@ -31,6 +31,9 @@ const initialState: ChannelConnectionsState = {
// populates them when the user wires up credentials.
lark: makeEmptyChannelModes(),
dingtalk: makeEmptyChannelModes(),
+ // MCP Servers tab is a virtual channel — no auth-mode connections,
+ // but must be present to satisfy Record.
+ mcp: makeEmptyChannelModes(),
},
};
@@ -67,6 +70,9 @@ const channelConnectionsSlice = createSlice({
// being undefined. Pin them by default so the migration is total.
state.connections.lark = makeEmptyChannelModes();
state.connections.dingtalk = makeEmptyChannelModes();
+ // MCP virtual channel must be present in persisted states migrated from
+ // before PR #2276 or the Record shape is incomplete.
+ state.connections.mcp = makeEmptyChannelModes();
state.defaultMessagingChannel = 'telegram';
state.migrationCompleted = true;
state.schemaVersion = SCHEMA_VERSION;
diff --git a/app/src/types/channels.ts b/app/src/types/channels.ts
index 353c0be72..ad3be4cf1 100644
--- a/app/src/types/channels.ts
+++ b/app/src/types/channels.ts
@@ -1,4 +1,4 @@
-export type ChannelType = 'telegram' | 'discord' | 'web' | 'lark' | 'dingtalk';
+export type ChannelType = 'telegram' | 'discord' | 'web' | 'lark' | 'dingtalk' | 'mcp';
export type ChannelAuthMode = 'managed_dm' | 'oauth' | 'bot_token' | 'api_key';
diff --git a/src/core/all.rs b/src/core/all.rs
index 8f89c5675..42e0b0ce8 100644
--- a/src/core/all.rs
+++ b/src/core/all.rs
@@ -114,6 +114,8 @@ fn build_registered_controllers() -> Vec {
controllers.extend(crate::openhuman::composio::all_composio_registered_controllers());
// Scheduled job management
controllers.extend(crate::openhuman::cron::all_cron_registered_controllers());
+ // MCP client subsystem: Smithery registry browser, local server install/connect, tool dispatch
+ controllers.extend(crate::openhuman::mcp_clients::all_mcp_clients_registered_controllers());
// Webview APIs bridge — proxies connector calls (Gmail, …) through
// a WebSocket to the Tauri shell so curl reaches the live webview.
controllers.extend(crate::openhuman::webview_apis::all_webview_apis_registered_controllers());
@@ -275,6 +277,7 @@ fn build_declared_controller_schemas() -> Vec {
schemas.extend(crate::openhuman::audio_toolkit::all_audio_toolkit_controller_schemas());
schemas.extend(crate::openhuman::composio::all_composio_controller_schemas());
schemas.extend(crate::openhuman::cron::all_cron_controller_schemas());
+ schemas.extend(crate::openhuman::mcp_clients::all_mcp_clients_controller_schemas());
schemas.extend(crate::openhuman::webview_apis::all_webview_apis_controller_schemas());
schemas.extend(crate::openhuman::agent::all_agent_controller_schemas());
schemas.extend(crate::openhuman::agent_experience::all_agent_experience_controller_schemas());
@@ -386,6 +389,9 @@ pub fn namespace_description(namespace: &str) -> Option<&'static str> {
"Connectivity diagnostics for the local sidecar, listening port, and backend Socket.IO state.",
),
"cron" => Some("Manage scheduled jobs and run history."),
+ "mcp_clients" => Some(
+ "Browse the Smithery.ai MCP registry, install MCP servers locally, manage their stdio connections, and expose their tools to the agent.",
+ ),
"decrypt" => Some("Decrypt secure values managed by secret storage."),
"doctor" => Some("Run diagnostics for workspace and runtime health."),
"encrypt" => Some("Encrypt secure values managed by secret storage."),
diff --git a/src/core/event_bus/events.rs b/src/core/event_bus/events.rs
index 33d161b73..52b0870ad 100644
--- a/src/core/event_bus/events.rs
+++ b/src/core/event_bus/events.rs
@@ -459,6 +459,27 @@ pub enum DomainEvent {
turn_count: usize,
},
+ // ── MCP Clients ─────────────────────────────────────────────────────
+ /// A new MCP server was installed from the Smithery registry.
+ McpServerInstalled {
+ server_id: String,
+ qualified_name: String,
+ },
+ /// An MCP server subprocess connected and completed the initialize handshake.
+ McpServerConnected { server_id: String, tool_count: u32 },
+ /// An MCP server subprocess was disconnected or terminated.
+ McpServerDisconnected {
+ server_id: String,
+ reason: Option,
+ },
+ /// An MCP client tool was invoked.
+ McpClientToolExecuted {
+ server_id: String,
+ tool_name: String,
+ success: bool,
+ elapsed_ms: u64,
+ },
+
// ── System lifecycle ────────────────────────────────────────────────
/// A system component started up.
SystemStartup { component: String },
@@ -569,6 +590,11 @@ impl DomainEvent {
Self::SessionExpired { .. } => "auth",
Self::ApprovalRequested { .. } | Self::ApprovalDecided { .. } => "approval",
+
+ Self::McpServerInstalled { .. }
+ | Self::McpServerConnected { .. }
+ | Self::McpServerDisconnected { .. }
+ | Self::McpClientToolExecuted { .. } => "mcp_client",
}
}
}
diff --git a/src/openhuman/about_app/catalog.rs b/src/openhuman/about_app/catalog.rs
index d04189b7c..b15b1bf08 100644
--- a/src/openhuman/about_app/catalog.rs
+++ b/src/openhuman/about_app/catalog.rs
@@ -957,6 +957,50 @@ const CAPABILITIES: &[Capability] = &[
status: CapabilityStatus::Beta,
privacy: LOCAL_RAW,
},
+ Capability {
+ id: "channels.mcp_registry_browse",
+ name: "Browse MCP Server Registry",
+ domain: "channels",
+ category: CapabilityCategory::Channels,
+ description: "Search and discover MCP servers from the Smithery.ai public registry.",
+ how_to: "Channels > MCP Servers > Browse Registry",
+ status: CapabilityStatus::Beta,
+ privacy: Some(CapabilityPrivacy {
+ leaves_device: true,
+ data_kind: PrivacyDataKind::Metadata,
+ destinations: &["Smithery.ai registry API"],
+ }),
+ },
+ Capability {
+ id: "channels.mcp_server_install",
+ name: "Install MCP Servers",
+ domain: "channels",
+ category: CapabilityCategory::Channels,
+ description: "Install MCP servers locally. Required env vars are stored encrypted and never included in logs or responses.",
+ how_to: "Channels > MCP Servers > Install",
+ status: CapabilityStatus::Beta,
+ privacy: LOCAL_CREDENTIALS,
+ },
+ Capability {
+ id: "channels.mcp_server_connect",
+ name: "Connect / Disconnect MCP Servers",
+ domain: "channels",
+ category: CapabilityCategory::Channels,
+ description: "Spawn and manage MCP server subprocesses via the stdio JSON-RPC protocol.",
+ how_to: "Channels > MCP Servers > Connect",
+ status: CapabilityStatus::Beta,
+ privacy: None,
+ },
+ Capability {
+ id: "channels.mcp_tool_call",
+ name: "Invoke MCP Server Tools",
+ domain: "channels",
+ category: CapabilityCategory::Channels,
+ description: "Call tools exposed by connected MCP servers. Results are surfaced to the agent.",
+ how_to: "Human > ask the assistant to use a tool from a connected MCP server",
+ status: CapabilityStatus::Beta,
+ privacy: None,
+ },
Capability {
id: "settings.configure_ai",
name: "Configure AI",
diff --git a/src/openhuman/mcp_clients/bus.rs b/src/openhuman/mcp_clients/bus.rs
new file mode 100644
index 000000000..dc90476b1
--- /dev/null
+++ b/src/openhuman/mcp_clients/bus.rs
@@ -0,0 +1,144 @@
+//! Event bus subscriber for the MCP clients domain.
+//!
+//! Logs lifecycle events (`McpServer*`, `McpClientToolExecuted`) for
+//! observability. No side effects are performed here — domain logic lives
+//! in `ops.rs` and `connections.rs`.
+
+use async_trait::async_trait;
+
+use crate::core::event_bus::{DomainEvent, EventHandler};
+
+/// Subscribes to `McpClient` domain events and emits structured log lines.
+pub struct McpClientEventSubscriber;
+
+#[async_trait]
+impl EventHandler for McpClientEventSubscriber {
+ fn name(&self) -> &str {
+ "mcp_client::lifecycle"
+ }
+
+ fn domains(&self) -> Option<&[&str]> {
+ Some(&["mcp_client"])
+ }
+
+ async fn handle(&self, event: &DomainEvent) {
+ match event {
+ DomainEvent::McpServerInstalled {
+ server_id,
+ qualified_name,
+ } => {
+ tracing::info!(
+ server_id = %server_id,
+ qualified_name = %qualified_name,
+ "[mcp-client] server installed"
+ );
+ }
+
+ DomainEvent::McpServerConnected {
+ server_id,
+ tool_count,
+ } => {
+ tracing::info!(
+ server_id = %server_id,
+ tool_count = %tool_count,
+ "[mcp-client] server connected"
+ );
+ }
+
+ DomainEvent::McpServerDisconnected { server_id, reason } => {
+ tracing::info!(
+ server_id = %server_id,
+ reason = ?reason,
+ "[mcp-client] server disconnected"
+ );
+ }
+
+ DomainEvent::McpClientToolExecuted {
+ server_id,
+ tool_name,
+ success,
+ elapsed_ms,
+ } => {
+ tracing::debug!(
+ server_id = %server_id,
+ tool_name = %tool_name,
+ success = %success,
+ elapsed_ms = %elapsed_ms,
+ "[mcp-client] tool executed"
+ );
+ }
+
+ _ => {}
+ }
+ }
+}
+
+/// Register the MCP client event subscriber at startup.
+///
+/// Call this from wherever other domain subscribers are registered
+/// (e.g. alongside `CronDeliverySubscriber::new(...)` in core startup).
+pub fn init() {
+ use crate::core::event_bus::subscribe_global;
+ use std::sync::Arc;
+ let sub = Arc::new(McpClientEventSubscriber);
+ if subscribe_global(sub).is_none() {
+ tracing::warn!("[mcp-client] event bus not initialized; subscriber not registered");
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[tokio::test]
+ async fn subscriber_ignores_unrelated_events() {
+ let sub = McpClientEventSubscriber;
+ // Should not panic on unrelated events
+ sub.handle(&DomainEvent::SystemStartup {
+ component: "test".to_string(),
+ })
+ .await;
+ }
+
+ #[tokio::test]
+ async fn subscriber_handles_mcp_installed_event() {
+ let sub = McpClientEventSubscriber;
+ sub.handle(&DomainEvent::McpServerInstalled {
+ server_id: "srv-1".to_string(),
+ qualified_name: "@test/server".to_string(),
+ })
+ .await;
+ }
+
+ #[tokio::test]
+ async fn subscriber_handles_mcp_connected_event() {
+ let sub = McpClientEventSubscriber;
+ sub.handle(&DomainEvent::McpServerConnected {
+ server_id: "srv-1".to_string(),
+ tool_count: 3,
+ })
+ .await;
+ }
+
+ #[tokio::test]
+ async fn subscriber_handles_mcp_disconnected_event() {
+ let sub = McpClientEventSubscriber;
+ sub.handle(&DomainEvent::McpServerDisconnected {
+ server_id: "srv-1".to_string(),
+ reason: Some("user request".to_string()),
+ })
+ .await;
+ }
+
+ #[tokio::test]
+ async fn subscriber_handles_tool_executed_event() {
+ let sub = McpClientEventSubscriber;
+ sub.handle(&DomainEvent::McpClientToolExecuted {
+ server_id: "srv-1".to_string(),
+ tool_name: "search".to_string(),
+ success: true,
+ elapsed_ms: 42,
+ })
+ .await;
+ }
+}
diff --git a/src/openhuman/mcp_clients/client/mod.rs b/src/openhuman/mcp_clients/client/mod.rs
new file mode 100644
index 000000000..4ab114f9d
--- /dev/null
+++ b/src/openhuman/mcp_clients/client/mod.rs
@@ -0,0 +1,290 @@
+//! MCP stdio client: spawns a child process and speaks the MCP JSON-RPC
+//! stdio protocol (initialize → tools/list → tools/call).
+//!
+//! The client is `Send + Sync` and is stored in a global registry keyed by
+//! `server_id`. Callers use the `McpTransport` trait for testing (see
+//! `protocol::McpTransport`).
+
+pub mod protocol;
+pub mod transport;
+
+use std::collections::HashMap;
+use std::sync::Arc;
+
+use async_trait::async_trait;
+use serde_json::{json, Value};
+use tokio::sync::Mutex;
+
+use crate::openhuman::mcp_clients::types::McpTool;
+
+use protocol::{
+ build_initialize_params, build_request, parse_tools_list, send_request_and_wait, McpTransport,
+ RequestIdCounter,
+};
+use transport::SpawnedProcess;
+
+// ── McpStdioClient ─────────────────────────────────────────────────────────
+
+/// A live connection to an MCP server over stdio.
+pub struct McpStdioClient {
+ server_id: String,
+ process: Mutex,
+ counter: RequestIdCounter,
+ /// Cached tool list after `initialize`.
+ cached_tools: Mutex>,
+}
+
+impl McpStdioClient {
+ /// Spawn the server process and run `initialize` + `tools/list`.
+ pub async fn spawn_and_init(
+ server_id: &str,
+ command: &str,
+ args: &[String],
+ env: &HashMap,
+ ) -> anyhow::Result> {
+ tracing::debug!(
+ "[mcp-client] spawn_and_init server_id={} command={} args={:?} env_keys={:?}",
+ server_id,
+ command,
+ args,
+ env.keys().collect::>()
+ );
+
+ let mut cmd = tokio::process::Command::new(command);
+ cmd.args(args)
+ .envs(env)
+ .stdin(std::process::Stdio::piped())
+ .stdout(std::process::Stdio::piped())
+ .stderr(std::process::Stdio::piped())
+ .kill_on_drop(true);
+
+ let child = cmd
+ .spawn()
+ .map_err(|e| anyhow::anyhow!("[mcp-client] failed to spawn {command}: {e}"))?;
+
+ let proc = SpawnedProcess::from_child(child, server_id)?;
+ let client = Arc::new(Self {
+ server_id: server_id.to_string(),
+ process: Mutex::new(proc),
+ counter: RequestIdCounter::new(),
+ cached_tools: Mutex::new(Vec::new()),
+ });
+
+ // Run MCP initialize handshake
+ let init_result = client.initialize().await.map_err(|e| {
+ anyhow::anyhow!("[mcp-client] server_id={server_id} initialize failed: {e}")
+ })?;
+ tracing::debug!(
+ "[mcp-client] server_id={} initialize result: {}",
+ server_id,
+ init_result
+ );
+
+ // Discover tools
+ let tools = client.list_tools().await.map_err(|e| {
+ anyhow::anyhow!("[mcp-client] server_id={server_id} tools/list failed: {e}")
+ })?;
+ tracing::debug!(
+ "[mcp-client] server_id={} discovered {} tools",
+ server_id,
+ tools.len()
+ );
+ {
+ let mut guard = client.cached_tools.lock().await;
+ *guard = tools;
+ }
+
+ Ok(client)
+ }
+
+ /// Return a snapshot of the cached tool list without a live RPC call.
+ pub async fn tools_snapshot(&self) -> Vec {
+ self.cached_tools.lock().await.clone()
+ }
+
+ /// Return the last stderr line for error reporting.
+ pub async fn last_error(&self) -> Option {
+ self.process.lock().await.reader.last_stderr().await
+ }
+}
+
+#[async_trait]
+impl McpTransport for McpStdioClient {
+ async fn initialize(&self) -> Result {
+ let id = self.counter.next().await;
+ let msg = build_request(id, "initialize", Some(build_initialize_params()));
+
+ let result = {
+ let mut proc = self.process.lock().await;
+ let pending = proc.reader.pending.clone();
+ let writer = &mut proc.writer;
+ // Register the pending waiter (inside send_request_and_wait) BEFORE
+ // performing the write, so a fast reply from the server isn't dropped
+ // by the reader before we're waiting for it.
+ send_request_and_wait(id, msg.clone(), &pending, async {
+ writer.send(&msg).await.map_err(|e| anyhow::anyhow!("{e}"))
+ })
+ .await
+ };
+
+ if result.is_ok() {
+ // Send initialized notification (no response expected)
+ let notif = json!({
+ "jsonrpc": "2.0",
+ "method": "notifications/initialized",
+ "params": {}
+ });
+ let mut proc = self.process.lock().await;
+ let _ = proc.writer.send(¬if).await;
+ }
+
+ result
+ }
+
+ async fn list_tools(&self) -> Result, String> {
+ let id = self.counter.next().await;
+ let msg = build_request(id, "tools/list", None);
+
+ let result = {
+ let mut proc = self.process.lock().await;
+ let pending = proc.reader.pending.clone();
+ let writer = &mut proc.writer;
+ send_request_and_wait(id, msg.clone(), &pending, async {
+ writer.send(&msg).await.map_err(|e| anyhow::anyhow!("{e}"))
+ })
+ .await
+ }?;
+
+ let tools = parse_tools_list(result)?;
+ // Update cache
+ let mut guard = self.cached_tools.lock().await;
+ *guard = tools.clone();
+ Ok(tools)
+ }
+
+ async fn call_tool(&self, tool_name: &str, arguments: Value) -> Result {
+ tracing::debug!(
+ "[mcp-client] server_id={} tool_call tool_name={}",
+ self.server_id,
+ tool_name
+ );
+ let id = self.counter.next().await;
+ let params = json!({
+ "name": tool_name,
+ "arguments": arguments
+ });
+ let msg = build_request(id, "tools/call", Some(params));
+
+ let mut proc = self.process.lock().await;
+ let pending = proc.reader.pending.clone();
+ let writer = &mut proc.writer;
+ send_request_and_wait(id, msg.clone(), &pending, async {
+ writer.send(&msg).await.map_err(|e| anyhow::anyhow!("{e}"))
+ })
+ .await
+ }
+
+ async fn shutdown(&self) {
+ tracing::debug!("[mcp-client] shutdown server_id={}", self.server_id);
+ let notif = json!({
+ "jsonrpc": "2.0",
+ "method": "notifications/cancelled",
+ "params": { "reason": "client shutdown" }
+ });
+ let mut proc = self.process.lock().await;
+ let _ = proc.writer.send(¬if).await;
+ let _ = proc.child.kill().await;
+ }
+}
+
+// ── FakeMcpTransport (test double) ──────────────────────────────────────────
+
+/// An in-memory fake for `McpTransport` usable in unit and E2E tests.
+/// Responds to `initialize`, `list_tools`, and `call_tool` without spawning
+/// any real process.
+pub struct FakeMcpTransport {
+ pub tools: Vec,
+ /// Canned result for `call_tool`. If `Err`, the call returns that error.
+ pub call_result: Result,
+}
+
+impl FakeMcpTransport {
+ pub fn new(tools: Vec, call_result: Result) -> Arc {
+ Arc::new(Self { tools, call_result })
+ }
+
+ pub fn empty() -> Arc {
+ Self::new(Vec::new(), Ok(Value::Null))
+ }
+}
+
+#[async_trait]
+impl McpTransport for FakeMcpTransport {
+ async fn initialize(&self) -> Result {
+ Ok(json!({
+ "protocolVersion": transport::MCP_PROTOCOL_VERSION,
+ "capabilities": {}
+ }))
+ }
+
+ async fn list_tools(&self) -> Result, String> {
+ Ok(self.tools.clone())
+ }
+
+ async fn call_tool(&self, _tool_name: &str, _arguments: Value) -> Result {
+ self.call_result.clone()
+ }
+
+ async fn shutdown(&self) {}
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use serde_json::json;
+
+ fn make_tool(name: &str) -> McpTool {
+ McpTool {
+ name: name.to_string(),
+ description: Some(format!("Description for {name}")),
+ input_schema: json!({ "type": "object" }),
+ }
+ }
+
+ #[tokio::test]
+ async fn fake_initialize_returns_protocol_version() {
+ let fake = FakeMcpTransport::empty();
+ let result = fake.initialize().await.unwrap();
+ assert_eq!(result["protocolVersion"], transport::MCP_PROTOCOL_VERSION);
+ }
+
+ #[tokio::test]
+ async fn fake_list_tools_returns_configured_tools() {
+ let tools = vec![make_tool("search"), make_tool("write")];
+ let fake = FakeMcpTransport::new(tools.clone(), Ok(Value::Null));
+ let listed = fake.list_tools().await.unwrap();
+ assert_eq!(listed.len(), 2);
+ assert_eq!(listed[0].name, "search");
+ }
+
+ #[tokio::test]
+ async fn fake_call_tool_returns_configured_result() {
+ let expected = json!({ "answer": 42 });
+ let fake = FakeMcpTransport::new(vec![], Ok(expected.clone()));
+ let result = fake.call_tool("any_tool", json!({})).await.unwrap();
+ assert_eq!(result, expected);
+ }
+
+ #[tokio::test]
+ async fn fake_call_tool_propagates_error() {
+ let fake = FakeMcpTransport::new(vec![], Err("tool failed".to_string()));
+ let err = fake.call_tool("tool", json!({})).await.unwrap_err();
+ assert_eq!(err, "tool failed");
+ }
+
+ #[tokio::test]
+ async fn fake_shutdown_does_not_panic() {
+ let fake = FakeMcpTransport::empty();
+ fake.shutdown().await;
+ }
+}
diff --git a/src/openhuman/mcp_clients/client/protocol.rs b/src/openhuman/mcp_clients/client/protocol.rs
new file mode 100644
index 000000000..c07edbb23
--- /dev/null
+++ b/src/openhuman/mcp_clients/client/protocol.rs
@@ -0,0 +1,215 @@
+//! MCP JSON-RPC protocol framing: request serialisation, response correlation,
+//! and higher-level method helpers (`initialize`, `tools/list`, `tools/call`).
+//!
+//! All methods are async and use a 30-second timeout by default.
+
+use std::sync::Arc;
+use std::time::Duration;
+
+use async_trait::async_trait;
+use serde_json::{json, Value};
+use tokio::sync::{oneshot, Mutex};
+
+use super::transport::{PendingMap, MCP_PROTOCOL_VERSION};
+use crate::openhuman::mcp_clients::types::McpTool;
+
+const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
+
+/// Trait abstracting the MCP stdio transport so tests can inject fakes.
+#[async_trait]
+pub trait McpTransport: Send + Sync + 'static {
+ /// Send an MCP `initialize` request and return the server's result.
+ async fn initialize(&self) -> Result;
+
+ /// Send a `tools/list` request and return the parsed tool list.
+ async fn list_tools(&self) -> Result, String>;
+
+ /// Send a `tools/call` request.
+ async fn call_tool(&self, tool_name: &str, arguments: Value) -> Result;
+
+ /// Gracefully shut down (send `notifications/cancelled` or just close).
+ async fn shutdown(&self);
+}
+
+// ── Shared request-counter helper ────────────────────────────────────────────
+
+pub struct RequestIdCounter(Arc>);
+
+impl RequestIdCounter {
+ pub fn new() -> Self {
+ Self(Arc::new(Mutex::new(0)))
+ }
+
+ pub async fn next(&self) -> u64 {
+ let mut guard = self.0.lock().await;
+ *guard += 1;
+ *guard
+ }
+}
+
+// ── Dispatch helper used by the real transport ────────────────────────────────
+
+/// Send one JSON-RPC request message and await the response with timeout.
+///
+/// The caller owns the `pending` map and the writer lock; this function
+/// inserts a oneshot sender into `pending`, writes the message, then
+/// awaits the receiver with `REQUEST_TIMEOUT`.
+pub async fn send_request_and_wait(
+ id: u64,
+ msg: Value,
+ pending: &PendingMap,
+ write_fn: impl std::future::Future