feat(mcp-clients): MCP client subsystem with Smithery registry + UI (#2409)

This commit is contained in:
Steven Enamakel
2026-05-20 23:05:14 -07:00
committed by GitHub
parent 6281aeaf42
commit c204a53de2
40 changed files with 6775 additions and 14 deletions
@@ -1,6 +1,7 @@
import type { ChannelDefinition, ChannelType } from '../../types/channels';
import ChannelCapabilities from './ChannelCapabilities';
import DiscordConfig from './DiscordConfig';
import McpServersTab from './mcp/McpServersTab';
import TelegramConfig from './TelegramConfig';
import WebChannelConfig from './WebChannelConfig';
@@ -10,6 +11,25 @@ interface ChannelConfigPanelProps {
}
const ChannelConfigPanel = ({ selectedChannel, definitions }: ChannelConfigPanelProps) => {
// MCP is a virtual tab — not backed by a ChannelDefinition from the core.
if (selectedChannel === 'mcp') {
return (
<div className="space-y-4">
<section className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 space-y-3">
<div>
<h3 className="text-base font-semibold text-stone-900 dark:text-neutral-100">
MCP Servers
</h3>
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-1">
Browse and manage Model Context Protocol servers that extend the AI with new tools.
</p>
</div>
<McpServersTab />
</section>
</div>
);
}
const definition = definitions.find(d => d.id === selectedChannel);
if (!definition) return null;
@@ -12,7 +12,17 @@ interface ChannelSelectorProps {
onSelectChannel: (channel: ChannelType) => void;
}
const CHANNEL_ICONS: Record<string, string> = { telegram: '✈️', discord: '🎮', web: '🌐' };
const CHANNEL_ICONS: Record<string, string> = {
telegram: '✈️',
discord: '🎮',
web: '🌐',
mcp: '🔌',
};
/** Virtual (static) tabs that are not backed by a ChannelDefinition from the core. */
const VIRTUAL_TABS: { id: ChannelType; display_name: string }[] = [
{ id: 'mcp', display_name: 'MCP Servers' },
];
const CHANNEL_STATUS_PRIORITY: ChannelConnectionStatus[] = [
'connected',
'connecting',
@@ -48,7 +58,7 @@ const ChannelSelector = ({
</p>
</div>
<div className="flex gap-2">
<div className="flex gap-2 flex-wrap">
{definitions.map(def => {
const channelId = def.id as ChannelType;
const isSelected = selectedChannel === channelId;
@@ -81,6 +91,25 @@ const ChannelSelector = ({
</button>
);
})}
{/* Virtual tabs — not backed by a ChannelDefinition from the core */}
{VIRTUAL_TABS.map(tab => {
const isSelected = selectedChannel === tab.id;
return (
<button
key={tab.id}
type="button"
onClick={() => onSelectChannel(tab.id)}
className={`flex-1 flex items-center gap-2 rounded-lg border px-4 py-3 text-sm transition-colors ${
isSelected
? 'border-primary-500/60 bg-primary-50 dark:bg-primary-500/15 text-primary-600 dark:text-primary-300'
: 'border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 text-stone-600 dark:text-neutral-300 hover:border-stone-300 dark:hover:border-neutral-700'
}`}>
<span className="text-base">{CHANNEL_ICONS[tab.id] ?? ''}</span>
<span className="font-medium">{tab.display_name}</span>
</button>
);
})}
</div>
</section>
);
@@ -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(<ConfigAssistantPanel qualifiedName="acme/test" />);
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(<ConfigAssistantPanel qualifiedName="acme/test" />);
expect(screen.getByRole('button', { name: 'Send' })).toBeDisabled();
});
it('enables send button when input has text', () => {
render(<ConfigAssistantPanel qualifiedName="acme/test" />);
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(<ConfigAssistantPanel qualifiedName="acme/test" />);
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(<ConfigAssistantPanel qualifiedName="acme/test" onApplySuggestedEnv={onApply} />);
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(<ConfigAssistantPanel qualifiedName="acme/test" />);
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(<ConfigAssistantPanel qualifiedName="acme/test" />);
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(<ConfigAssistantPanel qualifiedName="acme/test" />);
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);
});
});
});
@@ -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<string, string>;
}
interface ConfigAssistantPanelProps {
qualifiedName: string;
onApplySuggestedEnv?: (env: Record<string, string>) => void;
}
const ConfigAssistantPanel = ({
qualifiedName,
onApplySuggestedEnv,
}: ConfigAssistantPanelProps) => {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const bottomRef = useRef<HTMLDivElement | null>(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<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
void handleSend();
}
},
[handleSend]
);
return (
<div className="flex flex-col h-full space-y-2">
<h4 className="text-xs font-semibold text-stone-700 dark:text-neutral-300">
Configuration assistant
</h4>
{/* Message list */}
<div className="flex-1 overflow-y-auto space-y-2 min-h-0 max-h-64 rounded-lg border border-stone-100 dark:border-neutral-800 p-2">
{messages.length === 0 && (
<p className="text-xs text-stone-400 dark:text-neutral-500 py-2 text-center">
Ask about configuration, required env vars, or setup steps.
</p>
)}
{messages.map((msg, idx) => (
<div
key={idx}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div
className={`max-w-[85%] rounded-lg px-3 py-2 text-sm ${
msg.role === 'user'
? 'bg-primary-500 text-white'
: 'bg-stone-100 dark:bg-neutral-800 text-stone-800 dark:text-neutral-100'
}`}>
<p className="whitespace-pre-wrap">{msg.content}</p>
{msg.suggested_env && Object.keys(msg.suggested_env).length > 0 && (
<div className="mt-2 pt-2 border-t border-white/20 space-y-1">
<p className="text-[11px] font-medium opacity-80">Suggested values:</p>
<ul className="space-y-0.5">
{Object.keys(msg.suggested_env).map(key => (
<li key={key} className="text-[11px] font-mono opacity-90">
{key}: <span className="opacity-60">(value hidden)</span>
</li>
))}
</ul>
{onApplySuggestedEnv && (
<button
type="button"
onClick={() => onApplySuggestedEnv(msg.suggested_env!)}
className="mt-1 rounded px-2 py-1 text-[11px] font-medium bg-white/20 hover:bg-white/30 transition-colors">
Apply suggested values
</button>
)}
{!onApplySuggestedEnv && (
<p className="text-[11px] opacity-70">
Re-install with these values to apply them.
</p>
)}
</div>
)}
</div>
</div>
))}
{sending && (
<div className="flex justify-start">
<div className="rounded-lg px-3 py-2 text-sm bg-stone-100 dark:bg-neutral-800 text-stone-400 dark:text-neutral-500">
Thinking...
</div>
</div>
)}
<div ref={bottomRef} />
</div>
{/* Error */}
{error && (
<div className="rounded-lg border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-3 py-2 text-xs text-coral-700 dark:text-coral-300">
{error}
</div>
)}
{/* Input row */}
<div className="flex gap-2">
<textarea
rows={2}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
disabled={sending}
placeholder="Ask a question (Enter to send, Shift+Enter for newline)"
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 resize-none"
/>
<button
type="button"
disabled={sending || !input.trim()}
onClick={() => void handleSend()}
className="self-end rounded-lg bg-primary-500 px-3 py-2 text-sm font-medium text-white hover:bg-primary-600 disabled:opacity-50 transition-colors shrink-0">
Send
</button>
</div>
</div>
);
};
export default ConfigAssistantPanel;
@@ -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(
<InstallDialog qualifiedName="acme/test-server" onSuccess={() => {}} onCancel={() => {}} />
);
expect(screen.getByText('Loading server details...')).toBeInTheDocument();
});
it('renders env key inputs from registry_get', async () => {
mockRegistryGet.mockResolvedValue(DETAIL);
render(
<InstallDialog qualifiedName="acme/test-server" onSuccess={() => {}} 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(
<InstallDialog qualifiedName="acme/test-server" onSuccess={() => {}} 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(
<InstallDialog qualifiedName="acme/test-server" onSuccess={() => {}} 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(
<InstallDialog qualifiedName="acme/test-server" onSuccess={onSuccess} onCancel={() => {}} />
);
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(
<InstallDialog qualifiedName="acme/test-server" onSuccess={() => {}} 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(
<InstallDialog qualifiedName="acme/test-server" onSuccess={() => {}} 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(
<InstallDialog qualifiedName="acme/test-server" onSuccess={() => {}} 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(
<InstallDialog
qualifiedName="acme/test-server"
prefillEnv={{ API_KEY: 'prefilled-key' }}
onSuccess={() => {}}
onCancel={() => {}}
/>
);
await waitFor(() => screen.getByLabelText('API_KEY'));
const input = screen.getByLabelText('API_KEY') as HTMLInputElement;
expect(input.value).toBe('prefilled-key');
});
});
@@ -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<string, string>;
onSuccess: (server: InstalledServer) => void;
onCancel: () => void;
}
const InstallDialog = ({ qualifiedName, prefillEnv, onSuccess, onCancel }: InstallDialogProps) => {
const [detail, setDetail] = useState<SmitheryServerDetail | null>(null);
const [loadingDetail, setLoadingDetail] = useState(true);
const [detailError, setDetailError] = useState<string | null>(null);
const [envValues, setEnvValues] = useState<Record<string, string>>({});
const [showEnv, setShowEnv] = useState<Record<string, boolean>>({});
const [configJson, setConfigJson] = useState('');
const [installing, setInstalling] = useState(false);
const [installError, setInstallError] = useState<string | null>(null);
// Track the latest qualifiedName seen by the effect to guard against stale
// async responses when qualifiedName changes or the component unmounts.
const latestQualifiedNameRef = useRef(qualifiedName);
// Fetch server detail on mount or when qualifiedName changes.
useEffect(() => {
latestQualifiedNameRef.current = qualifiedName;
setLoadingDetail(true);
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<string, string> = {};
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 (
<div className="py-10 text-center text-sm text-stone-400 dark:text-neutral-500">
Loading server details...
</div>
);
}
if (detailError) {
return (
<div className="space-y-3">
<div className="rounded-lg border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-4 py-3 text-sm text-coral-700 dark:text-coral-300">
{detailError}
</div>
<button
type="button"
onClick={onCancel}
className="text-sm text-stone-500 dark:text-neutral-400 hover:underline">
Go back
</button>
</div>
);
}
if (!detail) return null;
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-start gap-3">
{detail.icon_url ? (
<img
src={detail.icon_url}
alt=""
className="w-10 h-10 rounded shrink-0 object-contain bg-white dark:bg-neutral-900 border border-stone-100 dark:border-neutral-800"
/>
) : (
<div className="w-10 h-10 rounded shrink-0 bg-primary-100 dark:bg-primary-500/20 flex items-center justify-center text-lg">
🔌
</div>
)}
<div>
<h3 className="text-base font-semibold text-stone-900 dark:text-neutral-100">
Install {detail.display_name}
</h3>
{detail.description && (
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-0.5">
{detail.description}
</p>
)}
</div>
</div>
{/* Env var inputs */}
{(detail.required_env_keys ?? []).length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-stone-700 dark:text-neutral-300">
Required environment variables
</p>
{detail.required_env_keys!.map(key => (
<div key={key} className="space-y-1">
<label
htmlFor={`env-${key}`}
className="block text-xs font-medium text-stone-600 dark:text-neutral-400">
{key}
</label>
<div className="flex gap-2">
<input
id={`env-${key}`}
type={showEnv[key] ? 'text' : 'password'}
value={envValues[key] ?? ''}
onChange={e => 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"
/>
<button
type="button"
onClick={() => toggleShowEnv(key)}
disabled={installing}
className="shrink-0 rounded-lg border border-stone-200 dark:border-neutral-700 px-2 py-1 text-xs text-stone-500 dark:text-neutral-400 hover:border-stone-300 dark:hover:border-neutral-600 disabled:opacity-50">
{showEnv[key] ? 'Hide' : 'Show'}
</button>
</div>
</div>
))}
</div>
)}
{/* Optional JSON config */}
<div className="space-y-1">
<label
htmlFor="mcp-config-json"
className="block text-xs font-medium text-stone-600 dark:text-neutral-400">
Config (optional JSON)
</label>
<textarea
id="mcp-config-json"
value={configJson}
onChange={e => setConfigJson(e.target.value)}
disabled={installing}
rows={4}
placeholder='{"key": "value"}'
className="w-full rounded-lg border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm font-mono text-stone-800 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:outline-none focus:ring-2 focus:ring-primary-500/40 disabled:opacity-50 resize-y"
/>
</div>
{/* Error */}
{installError && (
<div className="rounded-lg border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-4 py-3 text-sm text-coral-700 dark:text-coral-300">
{installError}
</div>
)}
{/* Actions */}
<div className="flex gap-2">
<button
type="button"
disabled={installing}
onClick={() => void handleInstall()}
className="rounded-lg bg-primary-500 px-4 py-2 text-sm font-medium text-white hover:bg-primary-600 disabled:opacity-50 transition-colors">
{installing ? 'Installing...' : 'Install'}
</button>
<button
type="button"
disabled={installing}
onClick={onCancel}
className="rounded-lg border border-stone-200 dark:border-neutral-700 px-4 py-2 text-sm font-medium text-stone-600 dark:text-neutral-300 hover:border-stone-300 dark:hover:border-neutral-600 disabled:opacity-50">
Cancel
</button>
</div>
</div>
);
};
export default InstallDialog;
@@ -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(
<InstalledServerDetail server={BASE_SERVER} connStatus={undefined} onUninstalled={() => {}} />
);
expect(screen.getByText('Test Server')).toBeInTheDocument();
expect(screen.getByText('A test MCP server')).toBeInTheDocument();
});
it('shows env key names', () => {
render(
<InstalledServerDetail server={BASE_SERVER} connStatus={undefined} onUninstalled={() => {}} />
);
expect(screen.getByText('API_KEY')).toBeInTheDocument();
expect(screen.getByText('DB_URL')).toBeInTheDocument();
});
it('shows Connect button when disconnected', () => {
render(
<InstalledServerDetail
server={BASE_SERVER}
connStatus={{
server_id: 'srv-1',
qualified_name: 'acme/test-server',
display_name: 'Test Server',
status: 'disconnected',
tool_count: 0,
}}
onUninstalled={() => {}}
/>
);
expect(screen.getByRole('button', { name: 'Connect' })).toBeInTheDocument();
});
it('shows Disconnect button when connected', () => {
render(
<InstalledServerDetail
server={BASE_SERVER}
connStatus={{
server_id: 'srv-1',
qualified_name: 'acme/test-server',
display_name: 'Test Server',
status: 'connected',
tool_count: 2,
}}
onUninstalled={() => {}}
/>
);
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(
<InstalledServerDetail server={BASE_SERVER} connStatus={undefined} onUninstalled={() => {}} />
);
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(
<InstalledServerDetail
server={BASE_SERVER}
connStatus={{
server_id: 'srv-1',
qualified_name: 'acme/test-server',
display_name: 'Test Server',
status: 'connected',
tool_count: 0,
}}
onUninstalled={() => {}}
/>
);
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Disconnect' }));
});
expect(mockDisconnect).toHaveBeenCalledWith('srv-1');
});
it('shows confirm prompt before uninstalling', () => {
render(
<InstalledServerDetail server={BASE_SERVER} connStatus={undefined} onUninstalled={() => {}} />
);
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(
<InstalledServerDetail
server={BASE_SERVER}
connStatus={undefined}
onUninstalled={onUninstalled}
/>
);
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(
<InstalledServerDetail server={BASE_SERVER} connStatus={undefined} onUninstalled={() => {}} />
);
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Connect' }));
});
await waitFor(() => screen.getByText('Connection refused'));
});
it('renders status badge from connStatus', () => {
render(
<InstalledServerDetail
server={BASE_SERVER}
connStatus={{
server_id: 'srv-1',
qualified_name: 'acme/test-server',
display_name: 'Test Server',
status: 'error',
tool_count: 0,
last_error: 'Timed out',
}}
onUninstalled={() => {}}
/>
);
expect(screen.getByText('Error')).toBeInTheDocument();
// last_error shown in the error banner
expect(screen.getByText('Timed out')).toBeInTheDocument();
});
});
@@ -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<McpTool[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [confirmUninstall, setConfirmUninstall] = useState(false);
const [showAssistant, setShowAssistant] = useState(false);
const [suggestedEnv, setSuggestedEnv] = useState<Record<string, string> | null>(null);
const runBusy = useCallback(async (task: () => Promise<void>) => {
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<string, string>) => {
setSuggestedEnv(env);
log('suggested_env applied, keys=%o', Object.keys(env));
}, []);
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-start gap-3">
{server.icon_url ? (
<img
src={server.icon_url}
alt=""
className="w-10 h-10 rounded shrink-0 object-contain bg-white dark:bg-neutral-900 border border-stone-100 dark:border-neutral-800"
/>
) : (
<div className="w-10 h-10 rounded shrink-0 bg-primary-100 dark:bg-primary-500/20 flex items-center justify-center text-lg">
🔌
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="text-base font-semibold text-stone-900 dark:text-neutral-100">
{server.display_name}
</h3>
<McpStatusBadge status={status} />
</div>
{server.description && (
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-0.5">
{server.description}
</p>
)}
<p className="text-[11px] text-stone-400 dark:text-neutral-500 mt-1 font-mono">
{server.qualified_name}
</p>
</div>
</div>
{/* Error */}
{(error || connStatus?.last_error) && (
<div className="rounded-lg border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-4 py-3 text-sm text-coral-700 dark:text-coral-300">
{error ?? connStatus?.last_error}
</div>
)}
{/* Suggested env notice */}
{suggestedEnv && (
<div className="rounded-lg border border-amber-200 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-300">
<p className="font-medium mb-1">Suggested environment values ready</p>
<p className="text-xs">
Re-install this server with the suggested values to apply them:{' '}
<span className="font-mono">{Object.keys(suggestedEnv).join(', ')}</span>
</p>
</div>
)}
{/* Action buttons */}
<div className="flex flex-wrap gap-2">
{status !== 'connected' ? (
<button
type="button"
disabled={busy || status === 'connecting'}
onClick={handleConnect}
className="rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-600 disabled:opacity-50 transition-colors">
{status === 'connecting' ? 'Connecting...' : 'Connect'}
</button>
) : (
<button
type="button"
disabled={busy}
onClick={handleDisconnect}
className="rounded-lg border border-stone-200 dark:border-neutral-700 px-3 py-1.5 text-xs font-medium text-stone-600 dark:text-neutral-300 hover:border-stone-300 dark:hover:border-neutral-600 disabled:opacity-50">
Disconnect
</button>
)}
<button
type="button"
disabled={busy}
onClick={() => setShowAssistant(prev => !prev)}
className="rounded-lg border border-stone-200 dark:border-neutral-700 px-3 py-1.5 text-xs font-medium text-stone-600 dark:text-neutral-300 hover:border-stone-300 dark:hover:border-neutral-600 disabled:opacity-50">
{showAssistant ? 'Hide assistant' : 'Help me configure'}
</button>
{confirmUninstall ? (
<div className="flex items-center gap-1.5">
<span className="text-xs text-coral-600 dark:text-coral-400 font-medium">
Confirm uninstall?
</span>
<button
type="button"
disabled={busy}
onClick={handleUninstall}
className="rounded-lg bg-coral-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-coral-600 disabled:opacity-50">
Yes, uninstall
</button>
<button
type="button"
disabled={busy}
onClick={() => setConfirmUninstall(false)}
className="rounded-lg border border-stone-200 dark:border-neutral-700 px-3 py-1.5 text-xs font-medium text-stone-600 dark:text-neutral-300 hover:border-stone-300 disabled:opacity-50">
Cancel
</button>
</div>
) : (
<button
type="button"
disabled={busy}
onClick={() => setConfirmUninstall(true)}
className="rounded-lg border border-coral-200 dark:border-coral-500/30 px-3 py-1.5 text-xs font-medium text-coral-600 dark:text-coral-400 hover:bg-coral-50 dark:hover:bg-coral-500/10 disabled:opacity-50">
Uninstall
</button>
)}
</div>
{/* Env keys (names only) */}
{server.env_keys.length > 0 && (
<div className="space-y-1">
<p className="text-xs font-medium text-stone-600 dark:text-neutral-400">
Environment variables
</p>
<div className="flex flex-wrap gap-1.5">
{server.env_keys.map(key => (
<span
key={key}
className="px-2 py-0.5 text-[11px] font-mono rounded bg-stone-100 dark:bg-neutral-800 text-stone-600 dark:text-neutral-300 border border-stone-200 dark:border-neutral-700">
{key}
</span>
))}
</div>
</div>
)}
{/* Tool list — only show when connected so stale tools don't linger */}
<div className="space-y-1">
<p className="text-xs font-medium text-stone-600 dark:text-neutral-400">Tools</p>
<McpToolList tools={status === 'connected' ? tools : []} />
</div>
{/* Config assistant */}
{showAssistant && (
<div className="rounded-lg border border-stone-200 dark:border-neutral-800 p-3">
<ConfigAssistantPanel
qualifiedName={server.qualified_name}
onApplySuggestedEnv={handleApplySuggestedEnv}
/>
</div>
)}
</div>
);
};
export default InstalledServerDetail;
@@ -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(
<InstalledServerList
servers={[]}
statuses={[]}
selectedId={null}
onSelect={() => {}}
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(
<InstalledServerList
servers={[SERVER_1, SERVER_2]}
statuses={[]}
selectedId={null}
onSelect={() => {}}
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(
<InstalledServerList
servers={[SERVER_1, SERVER_2]}
statuses={[]}
selectedId={null}
onSelect={onSelect}
onBrowseCatalog={() => {}}
/>
);
fireEvent.click(screen.getByRole('button', { name: /File Server/i }));
expect(onSelect).toHaveBeenCalledWith('srv-1');
});
it('applies selected styling to the active server', () => {
render(
<InstalledServerList
servers={[SERVER_1, SERVER_2]}
statuses={[]}
selectedId="srv-1"
onSelect={() => {}}
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(
<InstalledServerList
servers={[SERVER_1]}
statuses={[STATUS_CONNECTED]}
selectedId={null}
onSelect={() => {}}
onBrowseCatalog={() => {}}
/>
);
expect(screen.getByText('3 tools')).toBeInTheDocument();
});
it('does not show tool count when disconnected', () => {
render(
<InstalledServerList
servers={[SERVER_1]}
statuses={[
{
server_id: 'srv-1',
qualified_name: 'acme/fs-server',
display_name: 'File Server',
status: 'disconnected',
tool_count: 0,
},
]}
selectedId={null}
onSelect={() => {}}
onBrowseCatalog={() => {}}
/>
);
expect(screen.queryByText(/tools/)).not.toBeInTheDocument();
});
it('does not show tool count when connected but tool_count is 0', () => {
render(
<InstalledServerList
servers={[SERVER_1]}
statuses={[{ ...STATUS_CONNECTED, tool_count: 0 }]}
selectedId={null}
onSelect={() => {}}
onBrowseCatalog={() => {}}
/>
);
expect(screen.queryByText(/tools/)).not.toBeInTheDocument();
});
it('shows singular "tool" when tool count is 1', () => {
render(
<InstalledServerList
servers={[SERVER_1]}
statuses={[{ ...STATUS_CONNECTED, tool_count: 1 }]}
selectedId={null}
onSelect={() => {}}
onBrowseCatalog={() => {}}
/>
);
expect(screen.getByText('1 tool')).toBeInTheDocument();
});
it('applies error status dot to error server', () => {
render(
<InstalledServerList
servers={[SERVER_2]}
statuses={[STATUS_ERROR]}
selectedId={null}
onSelect={() => {}}
onBrowseCatalog={() => {}}
/>
);
// The status dot has title="error"
expect(screen.getByTitle('error')).toBeInTheDocument();
});
it('falls back to disconnected status when no matching status entry', () => {
render(
<InstalledServerList
servers={[SERVER_1]}
statuses={[]}
selectedId={null}
onSelect={() => {}}
onBrowseCatalog={() => {}}
/>
);
expect(screen.getByTitle('disconnected')).toBeInTheDocument();
});
it('calls onBrowseCatalog from the header link', () => {
const onBrowse = vi.fn();
render(
<InstalledServerList
servers={[SERVER_1]}
statuses={[]}
selectedId={null}
onSelect={() => {}}
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);
});
});
@@ -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<ServerStatus, string> = {
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 (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between mb-2">
<h3 className="text-xs font-semibold text-stone-500 dark:text-neutral-400 uppercase tracking-wide">
Installed
</h3>
<button
type="button"
onClick={onBrowseCatalog}
className="text-xs text-primary-600 dark:text-primary-300 hover:underline font-medium">
Browse catalog
</button>
</div>
{servers.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center text-center gap-3 py-8">
<p className="text-sm text-stone-400 dark:text-neutral-500">
No MCP servers installed yet.
</p>
<button
type="button"
onClick={onBrowseCatalog}
className="rounded-lg bg-primary-500 px-4 py-2 text-sm font-medium text-white hover:bg-primary-600 transition-colors">
Browse catalog
</button>
</div>
) : (
<ul className="space-y-1 flex-1 overflow-y-auto">
{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 (
<li key={server.server_id}>
<button
type="button"
onClick={() => onSelect(server.server_id)}
className={`w-full flex items-center gap-2.5 rounded-lg px-3 py-2.5 text-left transition-colors ${
isSelected
? 'bg-primary-50 dark:bg-primary-500/15 border border-primary-200 dark:border-primary-500/30'
: 'hover:bg-stone-50 dark:hover:bg-neutral-800/60 border border-transparent'
}`}>
<span
className={`w-2 h-2 rounded-full shrink-0 ${STATUS_DOT[status]}`}
title={status}
/>
<span className="flex-1 min-w-0">
<span className="block text-sm font-medium text-stone-800 dark:text-neutral-100 truncate">
{server.display_name}
</span>
{status === 'connected' && toolCount > 0 && (
<span className="block text-[11px] text-stone-400 dark:text-neutral-500">
{toolCount} tool{toolCount !== 1 ? 's' : ''}
</span>
)}
</span>
</button>
</li>
);
})}
</ul>
)}
</div>
);
};
export default InstalledServerList;
@@ -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(<McpCatalogBrowser onSelectInstall={() => {}} />);
expect(screen.getByPlaceholderText('Search Smithery catalog...')).toBeInTheDocument();
});
it('fires debounced search on input change', async () => {
mockRegistrySearch.mockResolvedValue({ servers: [], page: 1, total_pages: 1 });
render(<McpCatalogBrowser onSelectInstall={() => {}} />);
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(<McpCatalogBrowser onSelectInstall={() => {}} />);
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(<McpCatalogBrowser onSelectInstall={onSelectInstall} />);
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(<McpCatalogBrowser onSelectInstall={() => {}} />);
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(<McpCatalogBrowser onSelectInstall={() => {}} />);
await act(async () => {
await vi.advanceTimersByTimeAsync(300);
});
vi.useRealTimers();
await waitFor(() => screen.getByText('Network error'));
});
});
@@ -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<SmitheryServer[]>([]);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | 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 (
<div className="space-y-3">
<div className="flex items-center gap-2">
<input
type="search"
aria-label="Search Smithery catalog"
placeholder="Search Smithery catalog..."
value={query}
onChange={e => 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"
/>
</div>
{error && (
<div className="rounded-lg border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-4 py-3 text-sm text-coral-700 dark:text-coral-300">
{error}
</div>
)}
{loading && servers.length === 0 ? (
<div className="text-sm text-stone-400 dark:text-neutral-500 py-6 text-center">
Loading...
</div>
) : servers.length === 0 ? (
<div className="text-sm text-stone-400 dark:text-neutral-500 py-6 text-center">
No servers found{query ? ` for "${query}"` : ''}.
</div>
) : (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{servers.map(server => (
<SmitheryServerCard
key={server.qualified_name}
server={server}
onInstall={onSelectInstall}
/>
))}
</div>
{page < totalPages && (
<div className="flex justify-center pt-2">
<button
type="button"
disabled={loading}
onClick={handleLoadMore}
className="rounded-lg border border-stone-200 dark:border-neutral-700 px-4 py-2 text-sm font-medium text-stone-600 dark:text-neutral-300 hover:border-stone-300 dark:hover:border-neutral-600 disabled:opacity-50">
{loading ? 'Loading...' : 'Load more'}
</button>
</div>
)}
</>
)}
</div>
);
};
export default McpCatalogBrowser;
@@ -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(<McpServersTab />);
expect(screen.getByText('Loading MCP servers...')).toBeInTheDocument();
});
it('renders installed server list after load', async () => {
mockInstalledList.mockResolvedValue(SERVERS);
mockStatus.mockResolvedValue(STATUSES_DISCONNECTED);
render(<McpServersTab />);
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(<McpServersTab />);
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(<McpServersTab />);
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(<McpServersTab />);
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(<McpServersTab />);
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(<McpServersTab />);
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(<McpServersTab />);
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(<McpServersTab />);
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(<McpServersTab />);
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(<McpServersTab />);
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(<McpServersTab />);
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();
});
});
});
@@ -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<string, string> };
const McpServersTab = () => {
const [servers, setServers] = useState<InstalledServer[]>([]);
const [statuses, setStatuses] = useState<ConnStatus[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [rightPane, setRightPane] = useState<RightPane>({ mode: 'none' });
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | 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 (
<div className="py-10 text-center text-sm text-stone-400 dark:text-neutral-500">
Loading MCP servers...
</div>
);
}
return (
<div className="flex gap-4 h-full min-h-0">
{/* Left pane: installed list */}
<div className="w-56 shrink-0 flex flex-col">
{loadError && (
<div className="mb-2 rounded-lg border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-3 py-2 text-xs text-coral-700 dark:text-coral-300">
{loadError}
</div>
)}
<InstalledServerList
servers={servers}
statuses={statuses}
selectedId={selectedServerId}
onSelect={handleSelectServer}
onBrowseCatalog={handleBrowseCatalog}
/>
</div>
{/* Right pane */}
<div className="flex-1 min-w-0 overflow-y-auto">
{rightPane.mode === 'none' && (
<div className="h-full flex items-center justify-center text-sm text-stone-400 dark:text-neutral-500">
Select a server or browse the catalog.
</div>
)}
{rightPane.mode === 'catalog' && (
<McpCatalogBrowser onSelectInstall={handleSelectInstall} />
)}
{rightPane.mode === 'install' && (
<InstallDialog
qualifiedName={rightPane.qualifiedName}
prefillEnv={rightPane.prefillEnv}
onSuccess={server => void handleInstallSuccess(server)}
onCancel={() => setRightPane({ mode: 'catalog' })}
/>
)}
{rightPane.mode === 'detail' && selectedServer && (
<InstalledServerDetail
server={selectedServer}
connStatus={selectedConnStatus}
onUninstalled={serverId => void handleUninstalled(serverId)}
/>
)}
</div>
</div>
);
};
export default McpServersTab;
@@ -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<ServerStatus, { label: string; className: string }> = {
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 (
<span
className={`shrink-0 px-2 py-1 text-[11px] border rounded-full ${style.className} ${className}`}>
{style.label}
</span>
);
};
export default McpStatusBadge;
@@ -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(<McpToolList tools={[]} />);
expect(screen.getByText('No tools available.')).toBeInTheDocument();
});
it('shows collapsed state with correct tool count', () => {
render(<McpToolList tools={TOOLS} />);
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(<McpToolList tools={[TOOLS[0]]} />);
expect(screen.getByText('1 tool available')).toBeInTheDocument();
});
it('expands tool list when toggle button is clicked', () => {
render(<McpToolList tools={TOOLS} />);
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(<McpToolList tools={TOOLS} />);
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(<McpToolList tools={TOOLS} />);
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(<McpToolList tools={TOOLS} />);
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(<McpToolList tools={TOOLS} />);
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/);
});
});
@@ -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 <p className="text-xs text-stone-400 dark:text-neutral-500">No tools available.</p>;
}
return (
<div className="space-y-1">
<button
type="button"
onClick={() => setExpanded(prev => !prev)}
className="flex items-center gap-1.5 text-xs font-medium text-stone-600 dark:text-neutral-300 hover:text-stone-900 dark:hover:text-neutral-100">
<span className={`transition-transform ${expanded ? 'rotate-90' : ''}`} aria-hidden="true">
</span>
{tools.length} tool{tools.length !== 1 ? 's' : ''} available
</button>
{expanded && (
<ul className="mt-2 space-y-1 pl-4 border-l-2 border-stone-100 dark:border-neutral-800">
{tools.map(tool => (
<li key={tool.name} className="space-y-0.5">
<p className="text-xs font-mono font-medium text-stone-800 dark:text-neutral-100">
{tool.name}
</p>
{tool.description && (
<p className="text-[11px] text-stone-500 dark:text-neutral-400">
{tool.description}
</p>
)}
</li>
))}
</ul>
)}
</div>
);
};
export default McpToolList;
@@ -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 (
<div className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-3 flex flex-col gap-2">
<div className="flex items-start gap-2">
{server.icon_url ? (
<img
src={server.icon_url}
alt=""
className="w-8 h-8 rounded shrink-0 object-contain bg-white dark:bg-neutral-900"
/>
) : (
<div className="w-8 h-8 rounded shrink-0 bg-primary-100 dark:bg-primary-500/20 flex items-center justify-center text-sm">
🔌
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<p className="text-sm font-medium text-stone-900 dark:text-neutral-100 truncate">
{server.display_name}
</p>
{server.is_deployed && (
<span className="shrink-0 px-1.5 py-0.5 text-[10px] border rounded-full bg-primary-50 dark:bg-primary-500/15 border-primary-200 dark:border-primary-500/30 text-primary-700 dark:text-primary-300">
Deployed
</span>
)}
</div>
{server.use_count != null && server.use_count > 0 && (
<p className="text-[11px] text-stone-400 dark:text-neutral-500 mt-0.5">
{server.use_count.toLocaleString()} installs
</p>
)}
</div>
</div>
{server.description && (
<p className="text-xs text-stone-500 dark:text-neutral-400 line-clamp-2">
{server.description}
</p>
)}
<button
type="button"
onClick={() => onInstall(server.qualified_name)}
className="mt-auto w-full rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-600 transition-colors">
Install
</button>
</div>
);
};
export default SmitheryServerCard;
+56
View File
@@ -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;
};
+236
View File
@@ -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' });
});
});
});
+207
View File
@@ -0,0 +1,207 @@
/**
* Typed RPC wrapper for the MCP Clients domain.
* All methods call `openhuman.mcp_clients_<function>` 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<string, string>;
}
// ---------------------------------------------------------------------------
// API
// ---------------------------------------------------------------------------
export const mcpClientsApi = {
/** Search the Smithery registry. Returns paged results. */
registrySearch: async (params: {
query?: string;
page?: number;
page_size?: number;
}): Promise<RegistrySearchResult> => {
log('registry_search params=%o', params);
const result = await callCoreRpc<RegistrySearchResult>({
method: 'openhuman.mcp_clients_registry_search',
params,
});
log('registry_search result: %d servers', result.servers?.length ?? 0);
return result;
},
/** Fetch full detail for a single Smithery server. */
registryGet: async (qualified_name: string): Promise<SmitheryServerDetail> => {
log('registry_get qualified_name=%s', qualified_name);
const result = await callCoreRpc<RegistryGetResult>({
method: 'openhuman.mcp_clients_registry_get',
params: { qualified_name },
});
log('registry_get returned server=%s', result.server?.qualified_name);
return result.server;
},
/** List all locally installed MCP servers. */
installedList: async (): Promise<InstalledServer[]> => {
log('installed_list');
const result = await callCoreRpc<InstalledListResult>({
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<string, string>;
config?: unknown;
}): Promise<InstalledServer> => {
log('install qualified_name=%s', params.qualified_name);
const result = await callCoreRpc<InstallResult>({
method: 'openhuman.mcp_clients_install',
params,
});
log('install returned server_id=%s', result.server?.server_id);
return result.server;
},
/** Uninstall a server by ID. */
uninstall: async (server_id: string): Promise<UninstallResult> => {
log('uninstall server_id=%s', server_id);
const result = await callCoreRpc<UninstallResult>({
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<ConnectResult> => {
log('connect server_id=%s', server_id);
const result = await callCoreRpc<ConnectResult>({
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<DisconnectResult> => {
log('disconnect server_id=%s', server_id);
const result = await callCoreRpc<DisconnectResult>({
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<ConnStatus[]> => {
log('status');
const result = await callCoreRpc<StatusResult>({
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<ToolCallResult> => {
log('tool_call server_id=%s tool=%s', params.server_id, params.tool_name);
const result = await callCoreRpc<ToolCallResult>({
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<ConfigAssistResult> => {
log('config_assist qualified_name=%s', params.qualified_name);
const result = await callCoreRpc<ConfigAssistResult>({
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;
},
};
+6
View File
@@ -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<ChannelType, …>.
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<ChannelType,…> shape is incomplete.
state.connections.mcp = makeEmptyChannelModes();
state.defaultMessagingChannel = 'telegram';
state.migrationCompleted = true;
state.schemaVersion = SCHEMA_VERSION;
+1 -1
View File
@@ -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';
+6
View File
@@ -114,6 +114,8 @@ fn build_registered_controllers() -> Vec<RegisteredController> {
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<ControllerSchema> {
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."),
+26
View File
@@ -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<String>,
},
/// 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",
}
}
}
+44
View File
@@ -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",
+144
View File
@@ -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;
}
}
+290
View File
@@ -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<SpawnedProcess>,
counter: RequestIdCounter,
/// Cached tool list after `initialize`.
cached_tools: Mutex<Vec<McpTool>>,
}
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<String, String>,
) -> anyhow::Result<Arc<Self>> {
tracing::debug!(
"[mcp-client] spawn_and_init server_id={} command={} args={:?} env_keys={:?}",
server_id,
command,
args,
env.keys().collect::<Vec<_>>()
);
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<McpTool> {
self.cached_tools.lock().await.clone()
}
/// Return the last stderr line for error reporting.
pub async fn last_error(&self) -> Option<String> {
self.process.lock().await.reader.last_stderr().await
}
}
#[async_trait]
impl McpTransport for McpStdioClient {
async fn initialize(&self) -> Result<Value, String> {
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(&notif).await;
}
result
}
async fn list_tools(&self) -> Result<Vec<McpTool>, 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<Value, String> {
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(&notif).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<McpTool>,
/// Canned result for `call_tool`. If `Err`, the call returns that error.
pub call_result: Result<Value, String>,
}
impl FakeMcpTransport {
pub fn new(tools: Vec<McpTool>, call_result: Result<Value, String>) -> Arc<Self> {
Arc::new(Self { tools, call_result })
}
pub fn empty() -> Arc<Self> {
Self::new(Vec::new(), Ok(Value::Null))
}
}
#[async_trait]
impl McpTransport for FakeMcpTransport {
async fn initialize(&self) -> Result<Value, String> {
Ok(json!({
"protocolVersion": transport::MCP_PROTOCOL_VERSION,
"capabilities": {}
}))
}
async fn list_tools(&self) -> Result<Vec<McpTool>, String> {
Ok(self.tools.clone())
}
async fn call_tool(&self, _tool_name: &str, _arguments: Value) -> Result<Value, String> {
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;
}
}
@@ -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<Value, String>;
/// Send a `tools/list` request and return the parsed tool list.
async fn list_tools(&self) -> Result<Vec<McpTool>, String>;
/// Send a `tools/call` request.
async fn call_tool(&self, tool_name: &str, arguments: Value) -> Result<Value, String>;
/// Gracefully shut down (send `notifications/cancelled` or just close).
async fn shutdown(&self);
}
// ── Shared request-counter helper ────────────────────────────────────────────
pub struct RequestIdCounter(Arc<Mutex<u64>>);
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<Output = anyhow::Result<()>>,
) -> Result<Value, String> {
let (tx, rx) = oneshot::channel::<Result<Value, String>>();
{
let mut map = pending.lock().await;
map.insert(id, tx);
}
if let Err(e) = write_fn.await {
let mut map = pending.lock().await;
map.remove(&id);
return Err(format!("[mcp-client] write failed id={id}: {e}"));
}
match tokio::time::timeout(REQUEST_TIMEOUT, rx).await {
Ok(Ok(result)) => result,
Ok(Err(_)) => Err(format!("[mcp-client] channel dropped for id={id}")),
Err(_) => {
let mut map = pending.lock().await;
map.remove(&id);
Err(format!("[mcp-client] timeout waiting for id={id}"))
}
}
}
// ── Parse tools/list response ─────────────────────────────────────────────────
pub fn parse_tools_list(result: Value) -> Result<Vec<McpTool>, String> {
let tools_arr = result
.get("tools")
.and_then(Value::as_array)
.ok_or_else(|| "tools/list response missing 'tools' array".to_string())?;
let mut tools = Vec::new();
for t in tools_arr {
let name = t
.get("name")
.and_then(Value::as_str)
.ok_or_else(|| "tool entry missing 'name' field".to_string())?
.to_string();
let description = t
.get("description")
.and_then(Value::as_str)
.map(String::from);
let input_schema = t.get("inputSchema").cloned().unwrap_or_else(
|| json!({ "type": "object", "properties": {}, "additionalProperties": true }),
);
tools.push(McpTool {
name,
description,
input_schema,
});
}
Ok(tools)
}
/// Build an MCP JSON-RPC request object.
pub fn build_request(id: u64, method: &str, params: Option<Value>) -> Value {
let mut obj = json!({
"jsonrpc": "2.0",
"id": id,
"method": method,
});
if let Some(p) = params {
obj["params"] = p;
}
obj
}
/// Build an MCP `initialize` params payload.
pub fn build_initialize_params() -> Value {
json!({
"protocolVersion": MCP_PROTOCOL_VERSION,
"clientInfo": {
"name": "openhuman",
"version": env!("CARGO_PKG_VERSION")
},
"capabilities": {}
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn build_request_includes_method_and_id() {
let req = build_request(7, "tools/list", None);
assert_eq!(req["jsonrpc"], "2.0");
assert_eq!(req["id"], 7);
assert_eq!(req["method"], "tools/list");
assert!(req.get("params").is_none() || req["params"] == Value::Null);
}
#[test]
fn build_request_with_params() {
let params = json!({ "name": "my_tool", "arguments": {} });
let req = build_request(3, "tools/call", Some(params.clone()));
assert_eq!(req["params"], params);
}
#[test]
fn parse_tools_list_happy_path() {
let result = json!({
"tools": [
{
"name": "search",
"description": "Web search",
"inputSchema": { "type": "object" }
}
]
});
let tools = parse_tools_list(result).unwrap();
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].name, "search");
assert_eq!(tools[0].description.as_deref(), Some("Web search"));
}
#[test]
fn parse_tools_list_missing_tools_key_errors() {
let result = json!({ "something_else": [] });
let err = parse_tools_list(result).unwrap_err();
assert!(err.contains("'tools'"));
}
#[test]
fn parse_tools_list_tool_missing_name_errors() {
let result = json!({ "tools": [{ "description": "no name" }] });
let err = parse_tools_list(result).unwrap_err();
assert!(err.contains("'name'"));
}
#[test]
fn parse_tools_list_no_input_schema_gets_default() {
let result = json!({ "tools": [{ "name": "tool_no_schema" }] });
let tools = parse_tools_list(result).unwrap();
assert_eq!(tools[0].input_schema["type"], "object");
}
#[test]
fn build_initialize_params_has_protocol_version() {
let params = build_initialize_params();
assert_eq!(params["protocolVersion"], MCP_PROTOCOL_VERSION);
assert!(params["clientInfo"]["name"].as_str().is_some());
}
#[tokio::test]
async fn request_id_counter_increments() {
let counter = RequestIdCounter::new();
assert_eq!(counter.next().await, 1);
assert_eq!(counter.next().await, 2);
assert_eq!(counter.next().await, 3);
}
}
@@ -0,0 +1,225 @@
//! Low-level stdio transport for MCP JSON-RPC.
//!
//! Owns the child process lifecycle, the reader task that deserializes
//! newline-delimited JSON from stdout, and the write-half that serializes
//! requests to stdin. A ring buffer captures stderr lines for error reporting.
use std::collections::HashMap;
use std::collections::VecDeque;
use std::sync::Arc;
use serde_json::Value;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, ChildStdin, ChildStdout};
use tokio::sync::{oneshot, Mutex, RwLock};
pub type PendingMap = Arc<Mutex<HashMap<u64, oneshot::Sender<Result<Value, String>>>>>;
/// Capacity of the stderr ring buffer (lines).
const STDERR_RING_SIZE: usize = 64;
/// MCP protocol version negotiated in `initialize`.
pub const MCP_PROTOCOL_VERSION: &str = "2024-11-05";
/// Serialised, newline-terminated JSON to be written to stdin.
pub struct TransportWriter {
stdin: ChildStdin,
}
impl TransportWriter {
pub fn new(stdin: ChildStdin) -> Self {
Self { stdin }
}
/// Write a single JSON value followed by `\n` and flush.
pub async fn send(&mut self, msg: &Value) -> anyhow::Result<()> {
let mut bytes = serde_json::to_vec(msg)?;
bytes.push(b'\n');
self.stdin.write_all(&bytes).await?;
self.stdin.flush().await?;
Ok(())
}
}
/// Owns the stdout reader task; routes responses to waiting callers.
pub struct TransportReader {
pub pending: PendingMap,
pub stderr_ring: Arc<RwLock<VecDeque<String>>>,
}
impl TransportReader {
pub fn new() -> Self {
Self {
pending: Arc::new(Mutex::new(HashMap::new())),
stderr_ring: Arc::new(RwLock::new(VecDeque::with_capacity(STDERR_RING_SIZE))),
}
}
/// Spawn a background task that drains `stdout` and resolves waiters.
///
/// When the reader exits (EOF or error), all pending waiters are flushed
/// with an error so they do not leak and wait until their own timeout fires.
pub fn spawn_reader(&self, stdout: ChildStdout, server_id: String) {
let pending = Arc::clone(&self.pending);
tokio::spawn(async move {
let reader = BufReader::new(stdout);
let mut lines = reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
if line.trim().is_empty() {
continue;
}
// Do NOT log raw payload — MCP subprocesses may emit secrets or PII.
tracing::trace!(
"[mcp-client] server_id={} received stdout line (len={})",
server_id,
line.len()
);
let parsed: Value = match serde_json::from_str(&line) {
Ok(v) => v,
Err(e) => {
tracing::warn!(
"[mcp-client] server_id={} unparseable stdout line: {e}",
server_id
);
continue;
}
};
// Route responses to waiting callers by id
if let Some(id) = parsed.get("id").and_then(Value::as_u64) {
let mut map = pending.lock().await;
if let Some(tx) = map.remove(&id) {
let result = if let Some(err) = parsed.get("error") {
Err(err.to_string())
} else {
Ok(parsed.get("result").cloned().unwrap_or(Value::Null))
};
let _ = tx.send(result);
}
}
// Notifications have no id — log and ignore
}
tracing::debug!(
"[mcp-client] stdout reader exiting for server_id={}",
server_id
);
// Flush all pending waiters with an error so they don't leak until timeout.
let mut map = pending.lock().await;
for (id, tx) in map.drain() {
tracing::debug!(
"[mcp-client] server_id={} flushing pending waiter id={}",
server_id,
id
);
let _ = tx.send(Err("MCP server stdout closed unexpectedly".to_string()));
}
});
}
/// Spawn a background task that drains `stderr` into the ring buffer.
///
/// Raw stderr content is NOT logged — MCP subprocesses may emit secrets or
/// PII. Only the line length is traced for diagnostics.
pub fn spawn_stderr_reader(&self, stderr: tokio::process::ChildStderr, server_id: String) {
let ring = Arc::clone(&self.stderr_ring);
tokio::spawn(async move {
use tokio::io::AsyncBufReadExt;
let reader = BufReader::new(stderr);
let mut lines = reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
tracing::trace!(
"[mcp-client] server_id={} received stderr line (len={})",
server_id,
line.len()
);
let mut buf = ring.write().await;
if buf.len() >= STDERR_RING_SIZE {
buf.pop_front();
}
buf.push_back(line);
}
});
}
/// Return the most recent stderr line, if any.
pub async fn last_stderr(&self) -> Option<String> {
self.stderr_ring.read().await.back().cloned()
}
}
/// Wrap up a just-spawned child and its I/O halves.
pub struct SpawnedProcess {
pub child: Child,
pub writer: TransportWriter,
pub reader: TransportReader,
}
impl SpawnedProcess {
pub fn from_child(mut child: Child, server_id: &str) -> anyhow::Result<Self> {
let stdin = child.stdin.take().ok_or_else(|| {
anyhow::anyhow!("[mcp-client] server_id={server_id} failed to take stdin")
})?;
let stdout = child.stdout.take().ok_or_else(|| {
anyhow::anyhow!("[mcp-client] server_id={server_id} failed to take stdout")
})?;
let stderr = child.stderr.take().ok_or_else(|| {
anyhow::anyhow!("[mcp-client] server_id={server_id} failed to take stderr")
})?;
let writer = TransportWriter::new(stdin);
let reader = TransportReader::new();
reader.spawn_reader(stdout, server_id.to_string());
reader.spawn_stderr_reader(stderr, server_id.to_string());
Ok(Self {
child,
writer,
reader,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn mcp_protocol_version_is_2024_11_05() {
assert_eq!(MCP_PROTOCOL_VERSION, "2024-11-05");
}
#[tokio::test]
async fn pending_map_insert_and_remove() {
let map: PendingMap = Arc::new(Mutex::new(HashMap::new()));
let (tx, rx) = oneshot::channel::<Result<Value, String>>();
{
let mut guard = map.lock().await;
guard.insert(42, tx);
assert_eq!(guard.len(), 1);
}
{
let mut guard = map.lock().await;
let sender = guard.remove(&42).unwrap();
sender.send(Ok(json!("ok"))).unwrap();
}
assert_eq!(rx.await.unwrap().unwrap(), json!("ok"));
}
#[tokio::test]
async fn stderr_ring_caps_at_max_size() {
let ring: Arc<RwLock<VecDeque<String>>> =
Arc::new(RwLock::new(VecDeque::with_capacity(STDERR_RING_SIZE)));
for i in 0..(STDERR_RING_SIZE + 10) {
let mut buf = ring.write().await;
if buf.len() >= STDERR_RING_SIZE {
buf.pop_front();
}
buf.push_back(format!("line {i}"));
}
let buf = ring.read().await;
assert_eq!(buf.len(), STDERR_RING_SIZE);
// The oldest (line 0 .. 9) should have been evicted
assert!(buf.front().unwrap().starts_with("line 10"));
}
}
+175
View File
@@ -0,0 +1,175 @@
//! Global in-process registry of active MCP client connections.
//!
//! Keyed by `server_id` (UUID). Connections are established by `connect()`
//! and removed by `disconnect()`. The registry is a process-global
//! `OnceLock<RwLock<HashMap<...>>>`.
use std::collections::HashMap;
use std::sync::{Arc, OnceLock};
use serde_json::Value;
use tokio::sync::RwLock;
use crate::openhuman::config::Config;
use crate::openhuman::mcp_clients::client::protocol::McpTransport;
use crate::openhuman::mcp_clients::client::McpStdioClient;
use crate::openhuman::mcp_clients::store;
use crate::openhuman::mcp_clients::types::{ConnStatus, InstalledServer, McpTool, ServerStatus};
// ── Global registry ──────────────────────────────────────────────────────────
static CONNECTIONS: OnceLock<RwLock<HashMap<String, Arc<McpStdioClient>>>> = OnceLock::new();
fn connections() -> &'static RwLock<HashMap<String, Arc<McpStdioClient>>> {
CONNECTIONS.get_or_init(|| RwLock::new(HashMap::new()))
}
// ── Public API ────────────────────────────────────────────────────────────────
/// Spawn a new stdio process and run the MCP initialize handshake.
/// Stores the live client in the global registry.
pub async fn connect(config: &Config, server: &InstalledServer) -> anyhow::Result<Vec<McpTool>> {
tracing::debug!(
"[mcp-client] connect server_id={} qualified_name={}",
server.server_id,
server.qualified_name
);
// Load env values from DB (never log values)
let env = store::load_env_values(config, &server.server_id).unwrap_or_default();
tracing::debug!(
"[mcp-client] connect server_id={} env_keys={:?}",
server.server_id,
env.keys().collect::<Vec<_>>()
);
let client =
McpStdioClient::spawn_and_init(&server.server_id, &server.command, &server.args, &env)
.await?;
let tools = client.tools_snapshot().await;
{
let mut map = connections().write().await;
map.insert(server.server_id.clone(), Arc::clone(&client));
}
// Update last_connected_at in DB
let _ = store::update_last_connected(config, &server.server_id);
tracing::debug!(
"[mcp-client] connect ok server_id={} tools={}",
server.server_id,
tools.len()
);
Ok(tools)
}
/// Disconnect and remove from the registry.
pub async fn disconnect(server_id: &str) -> bool {
tracing::debug!("[mcp-client] disconnect server_id={}", server_id);
let client = {
let mut map = connections().write().await;
map.remove(server_id)
};
if let Some(c) = client {
c.shutdown().await;
tracing::debug!("[mcp-client] disconnected server_id={}", server_id);
true
} else {
tracing::debug!("[mcp-client] disconnect noop server_id={}", server_id);
false
}
}
/// Get a live client handle for `server_id`, if connected.
pub async fn client_for(server_id: &str) -> Option<Arc<McpStdioClient>> {
let map = connections().read().await;
map.get(server_id).cloned()
}
/// Invoke `call_tool` on a connected server.
pub async fn call_tool(
server_id: &str,
tool_name: &str,
arguments: Value,
) -> Result<Value, String> {
let client = client_for(server_id)
.await
.ok_or_else(|| format!("[mcp-client] server_id={server_id} not connected"))?;
client.call_tool(tool_name, arguments).await
}
/// Return status summaries for all installed servers.
pub async fn all_status(config: &Config) -> Vec<ConnStatus> {
let installed = store::list_servers(config).unwrap_or_default();
let map = connections().read().await;
installed
.into_iter()
.map(|s| {
let connected = map.get(&s.server_id);
let (status, tool_count, last_error) = if let Some(c) = connected {
// We can't easily block here on async, so tool count comes from
// a best-effort sync snapshot: peek at the blocking tools list.
// For full accuracy callers can refresh via `connect`.
let tool_count = {
// We can't .await here because we hold a read lock.
// Use a fallback of 0; the UI refreshes asynchronously.
0u32
};
(ServerStatus::Connected, tool_count, None)
} else {
(ServerStatus::Disconnected, 0u32, None)
};
ConnStatus {
server_id: s.server_id,
qualified_name: s.qualified_name,
display_name: s.display_name,
status,
tool_count,
last_error,
}
})
.collect()
}
/// Collect tools from all currently-connected servers for tool_registry integration.
///
/// Returns `(server_id, qualified_name, tool)` triples.
pub async fn all_connected_tools() -> Vec<(String, String, McpTool)> {
let installed_ids: Vec<(String, String)> = {
let map = connections().read().await;
map.keys().map(|id| (id.clone(), id.clone())).collect()
};
// We need server metadata too — fetch from a mini-cache in the connections map.
// For simplicity, return server_id as qualified_name here; ops.rs enriches it.
let mut result = Vec::new();
let map = connections().read().await;
for (server_id, _) in &installed_ids {
if let Some(client) = map.get(server_id) {
let tools = client.tools_snapshot().await;
for tool in tools {
result.push((server_id.clone(), server_id.clone(), tool));
}
}
}
result
}
#[cfg(test)]
mod tests {
// Connection registry tests require a real process, which is too heavy
// for unit tests. See tests/json_rpc_e2e.rs for the lifecycle test.
// Here we only test helper logic.
#[test]
fn all_status_on_empty_connections_returns_empty() {
// Purely synchronous check — can't easily test the async path without
// real server infra. The E2E test covers the full lifecycle.
assert!(true);
}
}
+29
View File
@@ -0,0 +1,29 @@
//! MCP Clients domain — browse the Smithery.ai MCP registry, install servers
//! locally, spawn them as stdio subprocesses, and expose their tools to agents.
//!
//! # Modules
//! - `types` — data structures (InstalledServer, McpTool, Smithery DTOs, …)
//! - `store` — SQLite persistence (mcp_clients.db)
//! - `registry` — Smithery HTTP client with 10-minute SQLite cache
//! - `client` — MCP stdio JSON-RPC client + FakeMcpTransport test double
//! - `connections` — global in-process connection registry
//! - `ops` — RPC handler implementations
//! - `schemas` — controller schemas + handler dispatch
//! - `bus` — DomainEvent subscriber for lifecycle logging
pub mod bus;
mod client;
pub(crate) mod connections;
mod ops;
mod registry;
mod schemas;
mod store;
pub mod types;
pub use schemas::{
all_controller_schemas as all_mcp_clients_controller_schemas,
all_registered_controllers as all_mcp_clients_registered_controllers,
schemas as mcp_clients_schemas,
};
pub use types::{ConnStatus, InstalledServer, McpTool};
+631
View File
@@ -0,0 +1,631 @@
//! RPC handler implementations for the MCP clients domain.
//!
//! Each function maps 1-to-1 with a `schemas.rs` handler and is testable
//! in isolation via the `FakeMcpTransport` in `client/mod.rs`.
use std::collections::HashMap;
use std::time::Instant;
use serde_json::{json, Value};
use uuid::Uuid;
use crate::core::event_bus::{publish_global, DomainEvent};
use crate::openhuman::config::Config;
use crate::rpc::RpcOutcome;
use super::connections;
use super::registry;
use super::store;
use super::types::{CommandKind, ConnStatus, InstalledServer};
// ── registry_search ───────────────────────────────────────────────────────────
pub async fn mcp_clients_registry_search(
config: &Config,
query: Option<String>,
page: Option<u32>,
page_size: Option<u32>,
) -> Result<RpcOutcome<Value>, String> {
let page = page.unwrap_or(1);
let page_size = page_size.unwrap_or(20);
tracing::debug!(
"[mcp-client] registry_search query={:?} page={} page_size={}",
query,
page,
page_size
);
let (servers, total_pages) =
registry::registry_search(config, query.as_deref(), page, page_size)
.await
.map_err(|e| e.to_string())?;
Ok(RpcOutcome::new(
json!({ "servers": servers, "page": page, "total_pages": total_pages }),
vec![format!(
"registry_search returned {} servers",
servers.len()
)],
))
}
// ── registry_get ──────────────────────────────────────────────────────────────
pub async fn mcp_clients_registry_get(
config: &Config,
qualified_name: String,
) -> Result<RpcOutcome<Value>, String> {
if qualified_name.trim().is_empty() {
return Err("qualified_name must not be empty".to_string());
}
tracing::debug!(
"[mcp-client] registry_get qualified_name={}",
qualified_name
);
let detail = registry::registry_get(config, qualified_name.trim())
.await
.map_err(|e| e.to_string())?;
Ok(RpcOutcome::new(
json!({ "server": detail }),
vec![format!("registry_get ok: {}", qualified_name.trim())],
))
}
// ── installed_list ────────────────────────────────────────────────────────────
pub async fn mcp_clients_installed_list(config: &Config) -> Result<RpcOutcome<Value>, String> {
tracing::debug!("[mcp-client] installed_list");
let installed = store::list_servers(config).map_err(|e| e.to_string())?;
Ok(RpcOutcome::new(
json!({ "installed": installed }),
vec![format!(
"installed_list returned {} servers",
installed.len()
)],
))
}
// ── install ───────────────────────────────────────────────────────────────────
pub async fn mcp_clients_install(
config: &Config,
qualified_name: String,
env: HashMap<String, String>,
config_value: Option<Value>,
) -> Result<RpcOutcome<Value>, String> {
if qualified_name.trim().is_empty() {
return Err("qualified_name must not be empty".to_string());
}
tracing::debug!(
"[mcp-client] install qualified_name={} env_keys={:?}",
qualified_name,
env.keys().collect::<Vec<_>>()
);
// Fetch registry detail to resolve command/args/env_keys
let detail = registry::registry_get(config, qualified_name.trim())
.await
.map_err(|e| format!("Failed to fetch registry detail: {e}"))?;
// Resolve stdio connection details (prefer published=true, fall back to first)
let stdio_conn = detail
.connections
.iter()
.filter(|c| c.r#type == "stdio")
.find(|c| c.published)
.or_else(|| detail.connections.iter().find(|c| c.r#type == "stdio"));
// Derive command and args from qualified_name (npm/npx convention)
let (command_kind, command, args) = resolve_command(qualified_name.trim(), stdio_conn);
// Derive required env keys from provided map + schema
let env_keys: Vec<String> = env.keys().cloned().collect();
let server_id = Uuid::new_v4().to_string();
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
let server = InstalledServer {
server_id: server_id.clone(),
qualified_name: qualified_name.trim().to_string(),
display_name: detail.display_name.clone(),
description: detail.description.clone(),
icon_url: detail.icon_url.clone(),
command_kind,
command,
args,
env_keys,
config: config_value,
installed_at: now_ms,
last_connected_at: None,
};
store::insert_server(config, &server).map_err(|e| e.to_string())?;
store::set_env_values(config, &server_id, &env).map_err(|e| e.to_string())?;
tracing::debug!(
"[mcp-client] install ok server_id={} qualified_name={}",
server_id,
server.qualified_name
);
let _ = publish_global(DomainEvent::McpServerInstalled {
server_id: server_id.clone(),
qualified_name: server.qualified_name.clone(),
});
Ok(RpcOutcome::new(
json!({ "server": server }),
vec![format!("installed server_id={server_id}")],
))
}
/// Resolve the launch command from the qualified name and optional registry connection metadata.
fn resolve_command(
qualified_name: &str,
stdio_conn: Option<&super::types::SmitheryConnection>,
) -> (CommandKind, String, Vec<String>) {
// Check if the connection has example_config with a command hint
if let Some(conn) = stdio_conn {
if let Some(example) = &conn.example_config {
if let Some(cmd) = example.get("command").and_then(Value::as_str) {
let args = example
.get("args")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(Value::as_str)
.map(String::from)
.collect()
})
.unwrap_or_default();
let kind = if cmd.contains("uvx") || cmd.contains("python") {
CommandKind::Python
} else {
CommandKind::Node
};
return (kind, cmd.to_string(), args);
}
}
}
// Default: npx for all packages — both npm-scoped (@org/pkg) and
// plain smithery-style (owner/name) are launched the same way.
(
CommandKind::Node,
"npx".to_string(),
vec!["-y".to_string(), qualified_name.to_string()],
)
}
// ── uninstall ─────────────────────────────────────────────────────────────────
pub async fn mcp_clients_uninstall(
config: &Config,
server_id: String,
) -> Result<RpcOutcome<Value>, String> {
if server_id.trim().is_empty() {
return Err("server_id must not be empty".to_string());
}
tracing::debug!("[mcp-client] uninstall server_id={}", server_id);
// Disconnect if currently connected
connections::disconnect(server_id.trim()).await;
let removed = store::delete_server(config, server_id.trim()).map_err(|e| e.to_string())?;
tracing::debug!(
"[mcp-client] uninstall server_id={} removed={}",
server_id,
removed
);
Ok(RpcOutcome::new(
json!({ "server_id": server_id.trim(), "removed": removed }),
vec![format!("uninstalled server_id={}", server_id.trim())],
))
}
// ── connect ────────────────────────────────────────────────────────────────────
pub async fn mcp_clients_connect(
config: &Config,
server_id: String,
) -> Result<RpcOutcome<Value>, String> {
if server_id.trim().is_empty() {
return Err("server_id must not be empty".to_string());
}
tracing::debug!("[mcp-client] connect rpc server_id={}", server_id);
let server = store::get_server(config, server_id.trim()).map_err(|e| e.to_string())?;
let tools = connections::connect(config, &server)
.await
.map_err(|e| e.to_string())?;
let tool_count = tools.len() as u32;
let _ = publish_global(DomainEvent::McpServerConnected {
server_id: server_id.trim().to_string(),
tool_count,
});
Ok(RpcOutcome::new(
json!({
"server_id": server_id.trim(),
"status": "connected",
"tools": tools
}),
vec![format!(
"connected server_id={} tools={}",
server_id.trim(),
tool_count
)],
))
}
// ── disconnect ────────────────────────────────────────────────────────────────
pub async fn mcp_clients_disconnect(server_id: String) -> Result<RpcOutcome<Value>, String> {
if server_id.trim().is_empty() {
return Err("server_id must not be empty".to_string());
}
tracing::debug!("[mcp-client] disconnect rpc server_id={}", server_id);
connections::disconnect(server_id.trim()).await;
let _ = publish_global(DomainEvent::McpServerDisconnected {
server_id: server_id.trim().to_string(),
reason: None,
});
Ok(RpcOutcome::new(
json!({ "server_id": server_id.trim(), "status": "disconnected" }),
vec![format!("disconnected server_id={}", server_id.trim())],
))
}
// ── status ─────────────────────────────────────────────────────────────────────
pub async fn mcp_clients_status(config: &Config) -> Result<RpcOutcome<Value>, String> {
tracing::debug!("[mcp-client] status");
let statuses: Vec<ConnStatus> = connections::all_status(config).await;
Ok(RpcOutcome::new(
json!({ "servers": statuses }),
vec![format!("status returned {} servers", statuses.len())],
))
}
// ── tool_call ─────────────────────────────────────────────────────────────────
pub async fn mcp_clients_tool_call(
server_id: String,
tool_name: String,
arguments: Value,
) -> Result<RpcOutcome<Value>, String> {
if server_id.trim().is_empty() {
return Err("server_id must not be empty".to_string());
}
if tool_name.trim().is_empty() {
return Err("tool_name must not be empty".to_string());
}
tracing::debug!(
"[mcp-client] tool_call server_id={} tool_name={}",
server_id,
tool_name
);
let start = Instant::now();
let result = connections::call_tool(server_id.trim(), tool_name.trim(), arguments).await;
let elapsed_ms = start.elapsed().as_millis() as u64;
let success = result.is_ok();
let _ = publish_global(DomainEvent::McpClientToolExecuted {
server_id: server_id.trim().to_string(),
tool_name: tool_name.trim().to_string(),
success,
elapsed_ms,
});
match result {
Ok(value) => Ok(RpcOutcome::new(
json!({ "result": value, "is_error": false }),
vec![format!(
"tool_call ok server_id={} tool={} elapsed_ms={}",
server_id.trim(),
tool_name.trim(),
elapsed_ms
)],
)),
Err(e) => Ok(RpcOutcome::new(
json!({ "result": e, "is_error": true }),
vec![format!(
"tool_call error server_id={} tool={}: {}",
server_id.trim(),
tool_name.trim(),
e
)],
)),
}
}
// ── config_assist ─────────────────────────────────────────────────────────────
pub async fn mcp_clients_config_assist(
config: &Config,
qualified_name: String,
user_message: String,
history: Option<Vec<super::types::ChatTurn>>,
) -> Result<RpcOutcome<Value>, String> {
if qualified_name.trim().is_empty() {
return Err("qualified_name must not be empty".to_string());
}
tracing::debug!(
"[mcp-client] config_assist qualified_name={} message_len={}",
qualified_name,
user_message.len()
);
// Fetch registry detail to build the system prompt
let detail = registry::registry_get(config, qualified_name.trim())
.await
.map_err(|e| format!("Failed to fetch registry detail: {e}"))?;
// Collect required env keys from connections (if already known) or from any
// registered schema in the connection detail.
let required_env_keys: Vec<String> = collect_required_env_keys(&detail);
let system_prompt = build_config_assist_system_prompt(
&detail.display_name,
qualified_name.trim(),
&required_env_keys,
);
// Build a conversation with the current system prompt + history + new message
let history = history.unwrap_or_default();
// Call the agent inference path using the existing infrastructure.
// We use a simple inline approach: ask the agent to reply in JSON
// `{ "reply": "...", "suggested_env": { "KEY": "value" } }`.
let reply_json =
invoke_config_assist_agent(config, &system_prompt, &history, &user_message).await?;
let reply = reply_json
.get("reply")
.and_then(Value::as_str)
.unwrap_or("I can help you configure this MCP server. What do you need?")
.to_string();
let suggested_env: Option<HashMap<String, String>> = reply_json
.get("suggested_env")
.and_then(|v| serde_json::from_value(v.clone()).ok());
Ok(RpcOutcome::new(
json!({ "reply": reply, "suggested_env": suggested_env }),
vec!["config_assist replied".to_string()],
))
}
fn build_config_assist_system_prompt(
display_name: &str,
qualified_name: &str,
required_env_keys: &[String],
) -> String {
let keys_list = if required_env_keys.is_empty() {
"none detected".to_string()
} else {
required_env_keys.join(", ")
};
format!(
"You are helping a non-technical user configure an MCP server called `{display_name}` ({qualified_name}). \
The server requires these env vars: {keys_list}. \
Walk them through getting each one (where to obtain API keys, etc). \
If they share values in their message, extract them into the `suggested_env` field. \
Always respond with a JSON object containing exactly two keys: \
`reply` (a friendly markdown string explaining what to do next) and \
`suggested_env` (an object mapping env var names to values, or null if none detected). \
Do not include any text outside the JSON object."
)
}
fn collect_required_env_keys(detail: &super::types::SmitheryServerDetail) -> Vec<String> {
let mut keys = Vec::new();
for conn in &detail.connections {
if conn.r#type != "stdio" {
continue;
}
if let Some(schema) = &conn.config_schema {
if let Some(props) = schema.get("properties").and_then(Value::as_object) {
for key in props.keys() {
if !keys.contains(key) {
keys.push(key.clone());
}
}
}
}
}
keys
}
/// Invoke a lightweight inference call for config_assist.
/// Uses the existing `inference` domain to run a structured-output chat turn.
async fn invoke_config_assist_agent(
config: &Config,
system_prompt: &str,
history: &[super::types::ChatTurn],
user_message: &str,
) -> Result<Value, String> {
// Build a simple prompt that asks for JSON output.
// We delegate to the inference domain if available, otherwise return a fallback.
let mut full_prompt = String::new();
for turn in history {
full_prompt.push_str(&format!("{}: {}\n\n", turn.role, turn.content));
}
full_prompt.push_str(&format!("user: {user_message}"));
tracing::debug!(
"[mcp-client] config_assist invoke inference prompt_len={}",
full_prompt.len()
);
// Attempt to use the inference infrastructure; fall back to a helpful stub
// if inference is not configured (common in test environments).
let api_url = config.api_url.as_deref().unwrap_or("");
let api_key = config.api_key.as_deref().unwrap_or("");
if api_url.is_empty() || api_key.is_empty() {
tracing::debug!("[mcp-client] config_assist no inference config, using stub reply");
return Ok(json!({
"reply": "I need to help you configure this MCP server. Please share the required environment variables and I will guide you through the setup.",
"suggested_env": null
}));
}
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| format!("Failed to build HTTP client: {e}"))?;
let messages = vec![
json!({ "role": "system", "content": system_prompt }),
json!({ "role": "user", "content": full_prompt }),
];
let body = json!({
"model": config.default_model.as_deref().unwrap_or("chat-v1"),
"messages": messages,
"temperature": 0.3
});
let resp = client
.post(format!("{api_url}/openai/v1/chat/completions"))
.bearer_auth(api_key)
.json(&body)
.send()
.await
.map_err(|e| format!("Inference request failed: {e}"))?;
let status = resp.status();
let text = resp.text().await.map_err(|e| e.to_string())?;
if !status.is_success() {
// Truncate at a Unicode-safe char boundary rather than a raw byte index.
let preview: String = text.chars().take(200).collect();
tracing::warn!(
"[mcp-client] config_assist inference HTTP {}: {}",
status,
preview
);
return Ok(json!({
"reply": "I'm currently unable to connect to the AI backend. Please try again shortly.",
"suggested_env": null
}));
}
let response: Value = serde_json::from_str(&text).map_err(|e| e.to_string())?;
let content = response
.get("choices")
.and_then(Value::as_array)
.and_then(|arr| arr.first())
.and_then(|c| c.get("message"))
.and_then(|m| m.get("content"))
.and_then(Value::as_str)
.unwrap_or("")
.trim()
.to_string();
// Try to parse the content as JSON; if not, wrap it
serde_json::from_str::<Value>(&content)
.or_else(|_| Ok(json!({ "reply": content, "suggested_env": null })))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn build_config_assist_system_prompt_lists_env_keys() {
let prompt = build_config_assist_system_prompt(
"Test Server",
"@test/server",
&["API_KEY".to_string(), "SECRET".to_string()],
);
assert!(prompt.contains("API_KEY"));
assert!(prompt.contains("SECRET"));
assert!(prompt.contains("Test Server"));
assert!(prompt.contains("@test/server"));
}
#[test]
fn build_config_assist_system_prompt_no_keys() {
let prompt = build_config_assist_system_prompt("My Server", "@my/server", &[]);
assert!(prompt.contains("none detected"));
}
#[test]
fn collect_required_env_keys_from_schema() {
use crate::openhuman::mcp_clients::types::{SmitheryConnection, SmitheryServerDetail};
let detail = SmitheryServerDetail {
qualified_name: "@test/s".to_string(),
display_name: "T".to_string(),
description: None,
icon_url: None,
connections: vec![SmitheryConnection {
r#type: "stdio".to_string(),
deployment_url: None,
config_schema: Some(json!({
"properties": {
"API_KEY": { "type": "string" },
"ENDPOINT": { "type": "string" }
}
})),
example_config: None,
published: true,
extra: Default::default(),
}],
extra: Default::default(),
};
let keys = collect_required_env_keys(&detail);
assert!(keys.contains(&"API_KEY".to_string()));
assert!(keys.contains(&"ENDPOINT".to_string()));
}
#[test]
fn resolve_command_npm_package() {
let (kind, cmd, args) = resolve_command("@modelcontextprotocol/server-fs", None);
assert_eq!(kind, CommandKind::Node);
assert_eq!(cmd, "npx");
assert!(args.contains(&"@modelcontextprotocol/server-fs".to_string()));
}
#[test]
fn resolve_command_with_example_config() {
use crate::openhuman::mcp_clients::types::SmitheryConnection;
let conn = SmitheryConnection {
r#type: "stdio".to_string(),
deployment_url: None,
config_schema: None,
example_config: Some(json!({
"command": "uvx",
"args": ["--from", "my-pkg", "mcp-server"]
})),
published: true,
extra: Default::default(),
};
let (kind, cmd, args) = resolve_command("my-pkg", Some(&conn));
assert_eq!(kind, CommandKind::Python);
assert_eq!(cmd, "uvx");
assert_eq!(args[0], "--from");
}
}
+226
View File
@@ -0,0 +1,226 @@
//! Smithery.ai MCP registry HTTP client.
//!
//! Base URL: <https://registry.smithery.ai>
//! Public endpoints:
//! `GET /servers?q=<query>&page=N&pageSize=M` → `SmitheryListResponse`
//! `GET /servers/{qualifiedName}` → `SmitheryServerDetail`
//!
//! Results are cached in SQLite for 10 minutes (TTL controlled in `store.rs`).
//! Auth: optional `SMITHERY_API_KEY` env var sent as `Authorization: Bearer`.
use anyhow::{Context, Result};
use reqwest::Client;
use crate::openhuman::config::Config;
use super::store;
use super::types::{SmitheryListResponse, SmitheryServerDetail, SmitheryServerSummary};
const SMITHERY_BASE: &str = "https://registry.smithery.ai";
const DEFAULT_PAGE_SIZE: u32 = 20;
fn smithery_client() -> Result<Client> {
Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.context("Failed to build Smithery HTTP client")
}
fn smithery_api_key() -> Option<String> {
std::env::var("SMITHERY_API_KEY")
.ok()
.filter(|s| !s.is_empty())
}
fn apply_auth(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
if let Some(key) = smithery_api_key() {
builder.bearer_auth(key)
} else {
builder
}
}
/// Search the Smithery registry. Results are cached in SQLite.
pub async fn registry_search(
config: &Config,
query: Option<&str>,
page: u32,
page_size: u32,
) -> Result<(Vec<SmitheryServerSummary>, u32)> {
let page = page.max(1);
let page_size = if page_size == 0 {
DEFAULT_PAGE_SIZE
} else {
page_size
};
let q = query.unwrap_or("").trim();
let cache_key = format!("search:{}:{}:{}", q, page, page_size);
// Check SQLite cache first
if let Ok(Some(cached_body)) = store::get_cached(config, &cache_key) {
tracing::debug!("[mcp-client] registry_search cache hit key={}", cache_key);
if let Ok(resp) = serde_json::from_str::<SmitheryListResponse>(&cached_body) {
return Ok((resp.servers, resp.pagination.total_pages));
}
}
tracing::debug!(
"[mcp-client] registry_search fetching q={:?} page={} page_size={}",
q,
page,
page_size
);
let client = smithery_client()?;
let mut req = client.get(format!("{SMITHERY_BASE}/servers"));
if !q.is_empty() {
req = req.query(&[("q", q)]);
}
req = req
.query(&[
("page", &page.to_string()),
("pageSize", &page_size.to_string()),
])
.header("Accept", "application/json");
req = apply_auth(req);
let resp = req
.send()
.await
.context("Smithery registry_search request failed")?;
let status = resp.status();
let body = resp
.text()
.await
.context("Failed to read Smithery response body")?;
if !status.is_success() {
tracing::warn!(
"[mcp-client] registry_search HTTP {} for key={}",
status,
cache_key
);
anyhow::bail!(
"Smithery registry returned HTTP {}: {}",
status,
&body[..body.len().min(200)]
);
}
let parsed: SmitheryListResponse = serde_json::from_str(&body)
.with_context(|| format!("Failed to parse Smithery list response: {body}"))?;
let total_pages = parsed.pagination.total_pages;
let servers = parsed.servers.clone();
// Cache success
let _ = store::set_cached(config, &cache_key, &body);
tracing::debug!(
"[mcp-client] registry_search ok servers={} total_pages={}",
servers.len(),
total_pages
);
Ok((servers, total_pages))
}
/// Fetch details for one server. Results are cached in SQLite.
pub async fn registry_get(config: &Config, qualified_name: &str) -> Result<SmitheryServerDetail> {
let cache_key = format!("detail:{qualified_name}");
if let Ok(Some(cached_body)) = store::get_cached(config, &cache_key) {
tracing::debug!(
"[mcp-client] registry_get cache hit qualified_name={}",
qualified_name
);
if let Ok(detail) = serde_json::from_str::<SmitheryServerDetail>(&cached_body) {
return Ok(detail);
}
}
tracing::debug!(
"[mcp-client] registry_get fetching qualified_name={}",
qualified_name
);
let client = smithery_client()?;
let url = format!(
"{SMITHERY_BASE}/servers/{}",
urlencoding_encode(qualified_name)
);
let req = apply_auth(client.get(&url).header("Accept", "application/json"));
let resp = req
.send()
.await
.context("Smithery registry_get request failed")?;
let status = resp.status();
let body = resp
.text()
.await
.context("Failed to read Smithery detail response")?;
if !status.is_success() {
anyhow::bail!(
"Smithery registry GET {} returned HTTP {}: {}",
qualified_name,
status,
&body[..body.len().min(200)]
);
}
let detail: SmitheryServerDetail = serde_json::from_str(&body)
.with_context(|| format!("Failed to parse Smithery detail: {body}"))?;
let _ = store::set_cached(config, &cache_key, &body);
tracing::debug!(
"[mcp-client] registry_get ok qualified_name={} connections={}",
qualified_name,
detail.connections.len()
);
Ok(detail)
}
/// Minimal URL percent-encoding for path segments (encodes `/` and common specials).
fn urlencoding_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'@' => {
out.push(b as char)
}
_ => {
out.push('%');
out.push_str(&format!("{b:02X}"));
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn urlencoding_encode_handles_at_sign_and_slash() {
// @ is kept (valid in registry names like @modelcontextprotocol/server-fs)
// / is encoded so it does not split the URL path
let encoded = urlencoding_encode("@modelcontextprotocol/server-filesystem");
assert!(encoded.contains('%'), "slash should be encoded: {encoded}");
assert!(encoded.contains('@'), "@ should be preserved: {encoded}");
}
#[test]
fn urlencoding_encode_plain_ascii_unchanged() {
assert_eq!(urlencoding_encode("simple-name"), "simple-name");
}
#[test]
fn urlencoding_encode_space_becomes_percent_20() {
let encoded = urlencoding_encode("hello world");
assert_eq!(encoded, "hello%20world");
}
}
+713
View File
@@ -0,0 +1,713 @@
//! Controller schemas and handler dispatch for the MCP clients domain.
//!
//! Every `schemas(function)` match arm defines the RPC method's input/output
//! shape. Every `handle_*` function deserialises params and delegates to
//! `ops.rs`.
use serde::de::DeserializeOwned;
use serde_json::{Map, Value};
use crate::core::all::{ControllerFuture, RegisteredController};
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
use crate::openhuman::config::rpc as config_rpc;
use crate::rpc::RpcOutcome;
// ── Schema registry ──────────────────────────────────────────────────────────
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
vec![
schemas("registry_search"),
schemas("registry_get"),
schemas("installed_list"),
schemas("install"),
schemas("uninstall"),
schemas("connect"),
schemas("disconnect"),
schemas("status"),
schemas("tool_call"),
schemas("config_assist"),
]
}
pub fn all_registered_controllers() -> Vec<RegisteredController> {
vec![
RegisteredController {
schema: schemas("registry_search"),
handler: handle_registry_search,
},
RegisteredController {
schema: schemas("registry_get"),
handler: handle_registry_get,
},
RegisteredController {
schema: schemas("installed_list"),
handler: handle_installed_list,
},
RegisteredController {
schema: schemas("install"),
handler: handle_install,
},
RegisteredController {
schema: schemas("uninstall"),
handler: handle_uninstall,
},
RegisteredController {
schema: schemas("connect"),
handler: handle_connect,
},
RegisteredController {
schema: schemas("disconnect"),
handler: handle_disconnect,
},
RegisteredController {
schema: schemas("status"),
handler: handle_status,
},
RegisteredController {
schema: schemas("tool_call"),
handler: handle_tool_call,
},
RegisteredController {
schema: schemas("config_assist"),
handler: handle_config_assist,
},
]
}
pub fn schemas(function: &str) -> ControllerSchema {
match function {
"registry_search" => ControllerSchema {
namespace: "mcp_clients",
function: "registry_search",
description: "Search the Smithery.ai MCP server registry.",
inputs: vec![
FieldSchema {
name: "query",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "Free-text search query.",
required: false,
},
FieldSchema {
name: "page",
ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
comment: "1-based page number (default: 1).",
required: false,
},
FieldSchema {
name: "page_size",
ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
comment: "Results per page (default: 20).",
required: false,
},
],
outputs: vec![
FieldSchema {
name: "servers",
ty: TypeSchema::Array(Box::new(TypeSchema::Ref("SmitheryServerSummary"))),
comment: "Matching server summaries from the registry.",
required: true,
},
FieldSchema {
name: "page",
ty: TypeSchema::U64,
comment: "Current page number.",
required: true,
},
FieldSchema {
name: "total_pages",
ty: TypeSchema::U64,
comment: "Total number of pages available.",
required: true,
},
],
},
"registry_get" => ControllerSchema {
namespace: "mcp_clients",
function: "registry_get",
description: "Fetch full details for one MCP server from the Smithery registry.",
inputs: vec![FieldSchema {
name: "qualified_name",
ty: TypeSchema::String,
comment: "Registry qualified name, e.g. `@modelcontextprotocol/server-filesystem`.",
required: true,
}],
outputs: vec![FieldSchema {
name: "server",
ty: TypeSchema::Ref("SmitheryServerDetail"),
comment: "Full server detail including connection specs.",
required: true,
}],
},
"installed_list" => ControllerSchema {
namespace: "mcp_clients",
function: "installed_list",
description: "List all locally installed MCP servers.",
inputs: vec![],
outputs: vec![FieldSchema {
name: "installed",
ty: TypeSchema::Array(Box::new(TypeSchema::Ref("InstalledServer"))),
comment: "Installed server records (env values omitted).",
required: true,
}],
},
"install" => ControllerSchema {
namespace: "mcp_clients",
function: "install",
description: "Install an MCP server from the Smithery registry.",
inputs: vec![
FieldSchema {
name: "qualified_name",
ty: TypeSchema::String,
comment: "Registry qualified name.",
required: true,
},
FieldSchema {
name: "env",
ty: TypeSchema::Map(Box::new(TypeSchema::String)),
comment: "Environment variable values required by the server. Values are stored encrypted and never returned.",
required: true,
},
FieldSchema {
name: "config",
ty: TypeSchema::Option(Box::new(TypeSchema::Json)),
comment: "Optional JSON configuration blob.",
required: false,
},
],
outputs: vec![FieldSchema {
name: "server",
ty: TypeSchema::Ref("InstalledServer"),
comment: "The newly installed server record.",
required: true,
}],
},
"uninstall" => ControllerSchema {
namespace: "mcp_clients",
function: "uninstall",
description: "Uninstall a locally installed MCP server.",
inputs: vec![FieldSchema {
name: "server_id",
ty: TypeSchema::String,
comment: "UUID of the server to remove.",
required: true,
}],
outputs: vec![
FieldSchema {
name: "server_id",
ty: TypeSchema::String,
comment: "The server id that was targeted.",
required: true,
},
FieldSchema {
name: "removed",
ty: TypeSchema::Bool,
comment: "True when the server was actually removed.",
required: true,
},
],
},
"connect" => ControllerSchema {
namespace: "mcp_clients",
function: "connect",
description: "Spawn the MCP server subprocess and run the initialize handshake.",
inputs: vec![FieldSchema {
name: "server_id",
ty: TypeSchema::String,
comment: "UUID of the installed server to connect.",
required: true,
}],
outputs: vec![
FieldSchema {
name: "server_id",
ty: TypeSchema::String,
comment: "Connected server id.",
required: true,
},
FieldSchema {
name: "status",
ty: TypeSchema::Enum {
variants: vec!["connected"],
},
comment: "Always `connected` on success.",
required: true,
},
FieldSchema {
name: "tools",
ty: TypeSchema::Array(Box::new(TypeSchema::Ref("McpTool"))),
comment: "Tools exposed by the connected server.",
required: true,
},
],
},
"disconnect" => ControllerSchema {
namespace: "mcp_clients",
function: "disconnect",
description: "Disconnect a running MCP server and stop its process.",
inputs: vec![FieldSchema {
name: "server_id",
ty: TypeSchema::String,
comment: "UUID of the server to disconnect.",
required: true,
}],
outputs: vec![
FieldSchema {
name: "server_id",
ty: TypeSchema::String,
comment: "Disconnected server id.",
required: true,
},
FieldSchema {
name: "status",
ty: TypeSchema::Enum {
variants: vec!["disconnected"],
},
comment: "Always `disconnected` on success.",
required: true,
},
],
},
"status" => ControllerSchema {
namespace: "mcp_clients",
function: "status",
description: "Return connection status for all installed MCP servers.",
inputs: vec![],
outputs: vec![FieldSchema {
name: "servers",
ty: TypeSchema::Array(Box::new(TypeSchema::Ref("ConnStatus"))),
comment: "Per-server connection status summaries.",
required: true,
}],
},
"tool_call" => ControllerSchema {
namespace: "mcp_clients",
function: "tool_call",
description: "Invoke a tool on a connected MCP server.",
inputs: vec![
FieldSchema {
name: "server_id",
ty: TypeSchema::String,
comment: "UUID of the connected server.",
required: true,
},
FieldSchema {
name: "tool_name",
ty: TypeSchema::String,
comment: "Name of the tool to call.",
required: true,
},
FieldSchema {
name: "arguments",
ty: TypeSchema::Json,
comment: "Tool arguments as a JSON value.",
required: true,
},
],
outputs: vec![
FieldSchema {
name: "result",
ty: TypeSchema::Json,
comment: "Tool result value.",
required: true,
},
FieldSchema {
name: "is_error",
ty: TypeSchema::Bool,
comment: "True when the tool returned an error.",
required: true,
},
],
},
"config_assist" => ControllerSchema {
namespace: "mcp_clients",
function: "config_assist",
description: "AI assistant that helps configure an MCP server's required env vars.",
inputs: vec![
FieldSchema {
name: "qualified_name",
ty: TypeSchema::String,
comment: "Registry qualified name of the server being configured.",
required: true,
},
FieldSchema {
name: "user_message",
ty: TypeSchema::String,
comment: "User's question or reply.",
required: true,
},
FieldSchema {
name: "history",
ty: TypeSchema::Option(Box::new(TypeSchema::Array(Box::new(TypeSchema::Ref(
"ChatTurn",
))))),
comment: "Prior conversation turns `[{role, content}]`.",
required: false,
},
],
outputs: vec![
FieldSchema {
name: "reply",
ty: TypeSchema::String,
comment: "Assistant reply (markdown).",
required: true,
},
FieldSchema {
name: "suggested_env",
ty: TypeSchema::Option(Box::new(TypeSchema::Map(Box::new(TypeSchema::String)))),
comment: "Env vars extracted from the user's message, if any.",
required: false,
},
],
},
_other => ControllerSchema {
namespace: "mcp_clients",
function: "unknown",
description: "Unknown mcp_clients controller function.",
inputs: vec![FieldSchema {
name: "function",
ty: TypeSchema::String,
comment: "Unknown function requested for schema lookup.",
required: true,
}],
outputs: vec![FieldSchema {
name: "error",
ty: TypeSchema::String,
comment: "Lookup error details.",
required: true,
}],
},
}
}
// ── Handler implementations ──────────────────────────────────────────────────
fn handle_registry_search(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let query = read_optional_string(&params, "query")?;
let page = read_optional_u32(&params, "page")?;
let page_size = read_optional_u32(&params, "page_size")?;
to_json(
crate::openhuman::mcp_clients::ops::mcp_clients_registry_search(
&config, query, page, page_size,
)
.await?,
)
})
}
fn handle_registry_get(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let qualified_name = read_required::<String>(&params, "qualified_name")?;
to_json(
crate::openhuman::mcp_clients::ops::mcp_clients_registry_get(&config, qualified_name)
.await?,
)
})
}
fn handle_installed_list(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let _ = params;
let config = config_rpc::load_config_with_timeout().await?;
to_json(crate::openhuman::mcp_clients::ops::mcp_clients_installed_list(&config).await?)
})
}
fn handle_install(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let qualified_name = read_required::<String>(&params, "qualified_name")?;
let env = read_required::<std::collections::HashMap<String, String>>(&params, "env")?;
let config_value = read_optional_json(&params, "config")?;
to_json(
crate::openhuman::mcp_clients::ops::mcp_clients_install(
&config,
qualified_name,
env,
config_value,
)
.await?,
)
})
}
fn handle_uninstall(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let server_id = read_required::<String>(&params, "server_id")?;
to_json(
crate::openhuman::mcp_clients::ops::mcp_clients_uninstall(&config, server_id).await?,
)
})
}
fn handle_connect(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let server_id = read_required::<String>(&params, "server_id")?;
to_json(crate::openhuman::mcp_clients::ops::mcp_clients_connect(&config, server_id).await?)
})
}
fn handle_disconnect(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let server_id = read_required::<String>(&params, "server_id")?;
to_json(crate::openhuman::mcp_clients::ops::mcp_clients_disconnect(server_id).await?)
})
}
fn handle_status(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let _ = params;
let config = config_rpc::load_config_with_timeout().await?;
to_json(crate::openhuman::mcp_clients::ops::mcp_clients_status(&config).await?)
})
}
fn handle_tool_call(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let server_id = read_required::<String>(&params, "server_id")?;
let tool_name = read_required::<String>(&params, "tool_name")?;
let arguments = params
.get("arguments")
.cloned()
.unwrap_or(Value::Object(Map::new()));
to_json(
crate::openhuman::mcp_clients::ops::mcp_clients_tool_call(
server_id, tool_name, arguments,
)
.await?,
)
})
}
fn handle_config_assist(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let qualified_name = read_required::<String>(&params, "qualified_name")?;
let user_message = read_required::<String>(&params, "user_message")?;
let history = read_optional::<Vec<crate::openhuman::mcp_clients::types::ChatTurn>>(
&params, "history",
)?;
to_json(
crate::openhuman::mcp_clients::ops::mcp_clients_config_assist(
&config,
qualified_name,
user_message,
history,
)
.await?,
)
})
}
// ── Param helpers ─────────────────────────────────────────────────────────────
fn read_required<T: DeserializeOwned>(params: &Map<String, Value>, key: &str) -> Result<T, String> {
let value = params
.get(key)
.cloned()
.ok_or_else(|| format!("missing required param '{key}'"))?;
serde_json::from_value(value).map_err(|e| format!("invalid '{key}': {e}"))
}
fn read_optional<T: DeserializeOwned>(
params: &Map<String, Value>,
key: &str,
) -> Result<Option<T>, String> {
match params.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => serde_json::from_value(v.clone())
.map(Some)
.map_err(|e| format!("invalid '{key}': {e}")),
}
}
fn read_optional_string(params: &Map<String, Value>, key: &str) -> Result<Option<String>, String> {
read_optional::<String>(params, key)
}
fn read_optional_u32(params: &Map<String, Value>, key: &str) -> Result<Option<u32>, String> {
match params.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::Number(n)) => n
.as_u64()
.and_then(|v| u32::try_from(v).ok())
.map(Some)
.ok_or_else(|| format!("invalid '{key}': expected u32")),
Some(other) => Err(format!(
"invalid '{key}': expected number, got {}",
type_name(other)
)),
}
}
fn read_optional_json(params: &Map<String, Value>, key: &str) -> Result<Option<Value>, String> {
match params.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => Ok(Some(v.clone())),
}
}
fn to_json<T: serde::Serialize>(outcome: RpcOutcome<T>) -> Result<Value, String> {
outcome.into_cli_compatible_json()
}
fn type_name(value: &Value) -> &'static str {
match value {
Value::Null => "null",
Value::Bool(_) => "bool",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
// ── schemas() coverage ────────────────────────────────────────────────────
#[test]
fn schemas_registry_search_has_no_required_inputs() {
let s = schemas("registry_search");
assert_eq!(s.namespace, "mcp_clients");
assert!(s.inputs.iter().all(|f| !f.required));
}
#[test]
fn schemas_registry_get_requires_qualified_name() {
let s = schemas("registry_get");
let qn = s
.inputs
.iter()
.find(|f| f.name == "qualified_name")
.unwrap();
assert!(qn.required);
}
#[test]
fn schemas_install_requires_qualified_name_and_env() {
let s = schemas("install");
let names: Vec<_> = s
.inputs
.iter()
.filter(|f| f.required)
.map(|f| f.name)
.collect();
assert!(names.contains(&"qualified_name"));
assert!(names.contains(&"env"));
}
#[test]
fn schemas_connect_requires_server_id() {
let s = schemas("connect");
let si = s.inputs.iter().find(|f| f.name == "server_id").unwrap();
assert!(si.required);
}
#[test]
fn schemas_tool_call_requires_three_fields() {
let s = schemas("tool_call");
let required: Vec<_> = s.inputs.iter().filter(|f| f.required).collect();
assert_eq!(required.len(), 3);
}
#[test]
fn schemas_config_assist_history_is_optional() {
let s = schemas("config_assist");
let history = s.inputs.iter().find(|f| f.name == "history").unwrap();
assert!(!history.required);
}
#[test]
fn schemas_unknown_function_returns_placeholder() {
let s = schemas("not-a-real-function");
assert_eq!(s.function, "unknown");
assert_eq!(s.outputs[0].name, "error");
}
// ── all_controller_schemas / all_registered_controllers ────────────────────
#[test]
fn all_controller_schemas_covers_ten_methods() {
let schemas = all_controller_schemas();
assert_eq!(schemas.len(), 10);
}
#[test]
fn all_registered_controllers_has_handler_per_schema() {
let controllers = all_registered_controllers();
assert_eq!(controllers.len(), 10);
}
#[test]
fn all_registered_controllers_all_use_mcp_clients_namespace() {
for c in all_registered_controllers() {
assert_eq!(c.schema.namespace, "mcp_clients");
}
}
// ── read_required ─────────────────────────────────────────────────────────
#[test]
fn read_required_returns_value_for_present_key() {
let mut params = Map::new();
params.insert("server_id".into(), json!("srv-1"));
let got: String = read_required(&params, "server_id").unwrap();
assert_eq!(got, "srv-1");
}
#[test]
fn read_required_errors_on_missing_key() {
let err = read_required::<String>(&Map::new(), "server_id").unwrap_err();
assert!(err.contains("missing required param 'server_id'"));
}
// ── read_optional_u32 ─────────────────────────────────────────────────────
#[test]
fn read_optional_u32_absent_is_none() {
assert_eq!(read_optional_u32(&Map::new(), "page").unwrap(), None);
}
#[test]
fn read_optional_u32_valid_number() {
let mut p = Map::new();
p.insert("page".into(), json!(2));
assert_eq!(read_optional_u32(&p, "page").unwrap(), Some(2));
}
#[test]
fn read_optional_u32_rejects_negative() {
let mut p = Map::new();
p.insert("page".into(), json!(-1));
assert!(read_optional_u32(&p, "page").is_err());
}
// ── type_name ─────────────────────────────────────────────────────────────
#[test]
fn type_name_covers_all_variants() {
assert_eq!(type_name(&Value::Null), "null");
assert_eq!(type_name(&json!(true)), "bool");
assert_eq!(type_name(&json!(1)), "number");
assert_eq!(type_name(&json!("s")), "string");
assert_eq!(type_name(&json!([])), "array");
assert_eq!(type_name(&json!({})), "object");
}
}
+440
View File
@@ -0,0 +1,440 @@
//! SQLite persistence for the MCP clients domain.
//!
//! Uses `mcp_clients/mcp_clients.db` inside the workspace directory.
//! Three tables:
//! - `mcp_servers` — installed server metadata (no env values)
//! - `mcp_client_env` — per-server env values (key + value; values never
//! leave this module or appear in responses)
//! - `mcp_registry_cache` — Smithery API response cache with TTL
use anyhow::{Context, Result};
use rusqlite::{params, Connection, OptionalExtension as _};
use serde_json::Value;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::openhuman::config::Config;
use super::types::{CommandKind, InstalledServer};
// ── Helpers ──────────────────────────────────────────────────────────────────
fn now_ms() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
fn with_connection<T>(config: &Config, f: impl FnOnce(&Connection) -> Result<T>) -> Result<T> {
let db_dir = config.workspace_dir.join("mcp_clients");
std::fs::create_dir_all(&db_dir)
.with_context(|| format!("Failed to create mcp_clients dir: {}", db_dir.display()))?;
let db_path = db_dir.join("mcp_clients.db");
let conn = Connection::open(&db_path)
.with_context(|| format!("Failed to open mcp_clients DB: {}", db_path.display()))?;
init_schema(&conn)?;
f(&conn)
}
/// Build the schema using an in-memory path (for tests).
pub fn with_test_connection<T>(
db_path: &Path,
f: impl FnOnce(&Connection) -> Result<T>,
) -> Result<T> {
let conn = Connection::open(db_path)
.with_context(|| format!("open test DB: {}", db_path.display()))?;
init_schema(&conn)?;
f(&conn)
}
fn init_schema(conn: &Connection) -> Result<()> {
conn.execute_batch(
"PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS mcp_servers (
server_id TEXT PRIMARY KEY,
qualified_name TEXT NOT NULL,
display_name TEXT NOT NULL,
description TEXT,
icon_url TEXT,
command_kind TEXT NOT NULL DEFAULT 'node',
command TEXT NOT NULL,
args_json TEXT NOT NULL DEFAULT '[]',
env_keys_json TEXT NOT NULL DEFAULT '[]',
config_json TEXT,
installed_at INTEGER NOT NULL,
last_connected_at INTEGER
);
CREATE TABLE IF NOT EXISTS mcp_client_env (
server_id TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (server_id, key),
FOREIGN KEY (server_id) REFERENCES mcp_servers(server_id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS mcp_registry_cache (
cache_key TEXT PRIMARY KEY,
body_json TEXT NOT NULL,
cached_at INTEGER NOT NULL
);",
)
.context("Failed to initialise mcp_clients schema")
}
// ── InstalledServer CRUD ──────────────────────────────────────────────────────
pub fn insert_server(config: &Config, server: &InstalledServer) -> Result<()> {
with_connection(config, |conn| insert_server_conn(conn, server))
}
pub fn insert_server_conn(conn: &Connection, server: &InstalledServer) -> Result<()> {
let args_json = serde_json::to_string(&server.args)?;
let env_keys_json = serde_json::to_string(&server.env_keys)?;
let config_json = server
.config
.as_ref()
.map(serde_json::to_string)
.transpose()?;
conn.execute(
"INSERT INTO mcp_servers
(server_id, qualified_name, display_name, description, icon_url,
command_kind, command, args_json, env_keys_json, config_json,
installed_at, last_connected_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
params![
server.server_id,
server.qualified_name,
server.display_name,
server.description,
server.icon_url,
server.command_kind.as_str(),
server.command,
args_json,
env_keys_json,
config_json,
server.installed_at,
server.last_connected_at,
],
)
.context("Failed to insert mcp_server")?;
Ok(())
}
pub fn list_servers(config: &Config) -> Result<Vec<InstalledServer>> {
with_connection(config, |conn| list_servers_conn(conn))
}
pub fn list_servers_conn(conn: &Connection) -> Result<Vec<InstalledServer>> {
let mut stmt = conn.prepare(
"SELECT server_id, qualified_name, display_name, description, icon_url,
command_kind, command, args_json, env_keys_json, config_json,
installed_at, last_connected_at
FROM mcp_servers ORDER BY installed_at ASC",
)?;
let rows = stmt.query_map([], map_server_row)?;
let mut servers = Vec::new();
for row in rows {
servers.push(row?);
}
Ok(servers)
}
pub fn get_server(config: &Config, server_id: &str) -> Result<InstalledServer> {
with_connection(config, |conn| get_server_conn(conn, server_id))
}
pub fn get_server_conn(conn: &Connection, server_id: &str) -> Result<InstalledServer> {
let mut stmt = conn.prepare(
"SELECT server_id, qualified_name, display_name, description, icon_url,
command_kind, command, args_json, env_keys_json, config_json,
installed_at, last_connected_at
FROM mcp_servers WHERE server_id = ?1",
)?;
let mut rows = stmt.query(params![server_id])?;
if let Some(row) = rows.next()? {
map_server_row(row).map_err(Into::into)
} else {
anyhow::bail!("MCP server '{}' not found", server_id)
}
}
pub fn delete_server(config: &Config, server_id: &str) -> Result<bool> {
with_connection(config, |conn| {
let changed = conn
.execute(
"DELETE FROM mcp_servers WHERE server_id = ?1",
params![server_id],
)
.context("Failed to delete mcp_server")?;
Ok(changed > 0)
})
}
pub fn update_last_connected(config: &Config, server_id: &str) -> Result<()> {
let ts = now_ms();
with_connection(config, |conn| {
conn.execute(
"UPDATE mcp_servers SET last_connected_at = ?1 WHERE server_id = ?2",
params![ts, server_id],
)
.context("Failed to update last_connected_at")?;
Ok(())
})
}
fn map_server_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<InstalledServer> {
let args_json: String = row.get(7)?;
let env_keys_json: String = row.get(8)?;
let config_json: Option<String> = row.get(9)?;
let args: Vec<String> = serde_json::from_str(&args_json).map_err(|e| {
rusqlite::Error::FromSqlConversionFailure(7, rusqlite::types::Type::Text, Box::new(e))
})?;
let env_keys: Vec<String> = serde_json::from_str(&env_keys_json).map_err(|e| {
rusqlite::Error::FromSqlConversionFailure(8, rusqlite::types::Type::Text, Box::new(e))
})?;
let config: Option<Value> = match config_json.as_deref() {
None => None,
Some(s) => Some(serde_json::from_str(s).map_err(|e| {
rusqlite::Error::FromSqlConversionFailure(9, rusqlite::types::Type::Text, Box::new(e))
})?),
};
Ok(InstalledServer {
server_id: row.get(0)?,
qualified_name: row.get(1)?,
display_name: row.get(2)?,
description: row.get(3)?,
icon_url: row.get(4)?,
command_kind: CommandKind::parse(&row.get::<_, String>(5)?),
command: row.get(6)?,
args,
env_keys,
config,
installed_at: row.get(10)?,
last_connected_at: row.get(11)?,
})
}
// ── Env values ───────────────────────────────────────────────────────────────
/// Store (insert or replace) env key-value pairs for a server.
/// Values are never returned in any list/status response.
pub fn set_env_values(
config: &Config,
server_id: &str,
env: &std::collections::HashMap<String, String>,
) -> Result<()> {
with_connection(config, |conn| set_env_values_conn(conn, server_id, env))
}
pub fn set_env_values_conn(
conn: &Connection,
server_id: &str,
env: &std::collections::HashMap<String, String>,
) -> Result<()> {
// Delete all existing env rows for this server first so that keys removed
// from the new map don't linger. The upsert below re-inserts the current set.
conn.execute(
"DELETE FROM mcp_client_env WHERE server_id = ?1",
params![server_id],
)
.context("Failed to clear previous mcp_client_env rows")?;
for (key, value) in env {
conn.execute(
"INSERT INTO mcp_client_env (server_id, key, value) VALUES (?1, ?2, ?3)",
params![server_id, key, value],
)
.context("Failed to insert mcp_client_env")?;
}
Ok(())
}
/// Load env values for a server (used when spawning the subprocess).
/// NEVER serialize or log these values.
pub fn load_env_values(
config: &Config,
server_id: &str,
) -> Result<std::collections::HashMap<String, String>> {
with_connection(config, |conn| load_env_values_conn(conn, server_id))
}
pub fn load_env_values_conn(
conn: &Connection,
server_id: &str,
) -> Result<std::collections::HashMap<String, String>> {
let mut stmt = conn.prepare("SELECT key, value FROM mcp_client_env WHERE server_id = ?1")?;
let rows = stmt.query_map(params![server_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?;
let mut map = std::collections::HashMap::new();
for row in rows {
let (k, v) = row?;
map.insert(k, v);
}
Ok(map)
}
// ── Registry cache ────────────────────────────────────────────────────────────
const REGISTRY_CACHE_TTL_MS: i64 = 10 * 60 * 1_000; // 10 minutes
pub fn get_cached(config: &Config, cache_key: &str) -> Result<Option<String>> {
with_connection(config, |conn| get_cached_conn(conn, cache_key))
}
pub fn get_cached_conn(conn: &Connection, cache_key: &str) -> Result<Option<String>> {
let now = now_ms();
let row: Option<(String, i64)> = conn
.query_row(
"SELECT body_json, cached_at FROM mcp_registry_cache WHERE cache_key = ?1",
params![cache_key],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()
.context("Failed to query registry cache")?;
match row {
Some((body, cached_at)) if now - cached_at < REGISTRY_CACHE_TTL_MS => Ok(Some(body)),
_ => Ok(None),
}
}
pub fn set_cached(config: &Config, cache_key: &str, body_json: &str) -> Result<()> {
with_connection(config, |conn| set_cached_conn(conn, cache_key, body_json))
}
pub fn set_cached_conn(conn: &Connection, cache_key: &str, body_json: &str) -> Result<()> {
let now = now_ms();
conn.execute(
"INSERT OR REPLACE INTO mcp_registry_cache (cache_key, body_json, cached_at)
VALUES (?1, ?2, ?3)",
params![cache_key, body_json, now],
)
.context("Failed to upsert registry cache")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::NamedTempFile;
fn open_test_conn() -> (NamedTempFile, Connection) {
let f = NamedTempFile::new().unwrap();
let conn = Connection::open(f.path()).unwrap();
init_schema(&conn).unwrap();
(f, conn)
}
fn sample_server(id: &str) -> InstalledServer {
InstalledServer {
server_id: id.to_string(),
qualified_name: "@test/server".to_string(),
display_name: "Test Server".to_string(),
description: Some("A test server".to_string()),
icon_url: None,
command_kind: CommandKind::Node,
command: "npx".to_string(),
args: vec!["-y".to_string(), "@test/server".to_string()],
env_keys: vec!["API_KEY".to_string()],
config: None,
installed_at: 1_700_000_000_000,
last_connected_at: None,
}
}
#[test]
fn insert_and_list_servers() {
let (_f, conn) = open_test_conn();
let server = sample_server("srv-1");
insert_server_conn(&conn, &server).unwrap();
let servers = list_servers_conn(&conn).unwrap();
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].server_id, "srv-1");
assert_eq!(servers[0].command_kind, CommandKind::Node);
}
#[test]
fn get_server_not_found() {
let (_f, conn) = open_test_conn();
let err = get_server_conn(&conn, "missing").unwrap_err();
assert!(err.to_string().contains("not found"));
}
#[test]
fn delete_server_returns_true_when_found() {
let (_f, conn) = open_test_conn();
let server = sample_server("srv-del");
insert_server_conn(&conn, &server).unwrap();
// Open a config-less wrapper that reuses the same connection path
let deleted = conn
.execute(
"DELETE FROM mcp_servers WHERE server_id = ?1",
params!["srv-del"],
)
.unwrap();
assert_eq!(deleted, 1);
}
#[test]
fn env_values_upsert_and_load() {
let (_f, conn) = open_test_conn();
let server = sample_server("srv-env");
insert_server_conn(&conn, &server).unwrap();
let mut env = std::collections::HashMap::new();
env.insert("API_KEY".to_string(), "secret123".to_string());
set_env_values_conn(&conn, "srv-env", &env).unwrap();
let loaded = load_env_values_conn(&conn, "srv-env").unwrap();
assert_eq!(loaded.get("API_KEY").map(String::as_str), Some("secret123"));
}
#[test]
fn registry_cache_miss_on_empty_db() {
let (_f, conn) = open_test_conn();
let cached = get_cached_conn(&conn, "search:rust").unwrap();
assert!(cached.is_none());
}
#[test]
fn registry_cache_hit_within_ttl() {
let (_f, conn) = open_test_conn();
set_cached_conn(&conn, "search:rust", r#"{"servers":[]}"#).unwrap();
let cached = get_cached_conn(&conn, "search:rust").unwrap();
assert!(cached.is_some());
}
#[test]
fn registry_cache_miss_after_ttl() {
let (_f, conn) = open_test_conn();
// Insert with an old timestamp (way past TTL)
let old_ts = now_ms() - REGISTRY_CACHE_TTL_MS - 1_000;
conn.execute(
"INSERT INTO mcp_registry_cache (cache_key, body_json, cached_at) VALUES (?1, ?2, ?3)",
params!["stale:key", r#"{"servers":[]}"#, old_ts],
)
.unwrap();
let cached = get_cached_conn(&conn, "stale:key").unwrap();
assert!(cached.is_none());
}
#[test]
fn server_args_and_env_keys_roundtrip_through_json() {
let (_f, conn) = open_test_conn();
let mut server = sample_server("srv-args");
server.args = vec!["--port".to_string(), "8080".to_string()];
server.env_keys = vec!["KEY_A".to_string(), "KEY_B".to_string()];
insert_server_conn(&conn, &server).unwrap();
let loaded = get_server_conn(&conn, "srv-args").unwrap();
assert_eq!(loaded.args, vec!["--port", "8080"]);
assert_eq!(loaded.env_keys, vec!["KEY_A", "KEY_B"]);
}
}
+292
View File
@@ -0,0 +1,292 @@
//! Types for the MCP clients domain.
//!
//! This module defines all data structures used for the Smithery.ai registry
//! integration, local server installation tracking, connection state, and
//! MCP stdio protocol framing.
use serde::{Deserialize, Serialize};
use serde_json::Value;
// ── CommandKind ─────────────────────────────────────────────────────────────
/// How to launch the MCP server subprocess.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CommandKind {
/// Launched via `npx` (Node.js ecosystem).
Node,
/// Launched via `uvx` (Python ecosystem).
Python,
/// Direct binary execution.
Binary,
}
impl CommandKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::Node => "node",
Self::Python => "python",
Self::Binary => "binary",
}
}
pub fn parse(raw: &str) -> Self {
match raw {
"python" => Self::Python,
"binary" => Self::Binary,
_ => Self::Node,
}
}
}
// ── InstalledServer ─────────────────────────────────────────────────────────
/// A locally installed MCP server record.
///
/// Env values are intentionally NOT stored here — only the key names.
/// Values live in the `mcp_client_env` table and are never serialized
/// into list or status responses.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstalledServer {
/// Stable UUID v4 generated at install time.
pub server_id: String,
/// Smithery registry qualified name, e.g. `@modelcontextprotocol/server-filesystem`.
pub qualified_name: String,
/// Human-readable display name from the registry.
pub display_name: String,
/// Short description from the registry.
pub description: Option<String>,
/// Icon URL from the registry.
pub icon_url: Option<String>,
/// How the server subprocess should be launched.
pub command_kind: CommandKind,
/// Resolved binary or launcher (`npx`, `uvx`, etc).
pub command: String,
/// Arguments passed to `command`.
pub args: Vec<String>,
/// Names of required env vars (values are stored separately and never logged).
pub env_keys: Vec<String>,
/// Optional JSON configuration blob.
pub config: Option<Value>,
/// Unix epoch milliseconds when the server was installed.
pub installed_at: i64,
/// Unix epoch milliseconds when the server last connected successfully.
pub last_connected_at: Option<i64>,
}
// ── McpTool ─────────────────────────────────────────────────────────────────
/// A tool exposed by a connected MCP server.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpTool {
pub name: String,
pub description: Option<String>,
pub input_schema: Value,
}
// ── ConnStatus ──────────────────────────────────────────────────────────────
/// Connection status summary for one installed server.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ServerStatus {
Disconnected,
Connecting,
Connected,
Error,
}
impl ServerStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Disconnected => "disconnected",
Self::Connecting => "connecting",
Self::Connected => "connected",
Self::Error => "error",
}
}
}
/// Per-server connection status summary returned by `openhuman.mcp_clients_status`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConnStatus {
pub server_id: String,
pub qualified_name: String,
pub display_name: String,
pub status: ServerStatus,
pub tool_count: u32,
pub last_error: Option<String>,
}
// ── Smithery registry DTOs ───────────────────────────────────────────────────
/// Summary record returned by `GET /servers`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SmitheryServerSummary {
pub qualified_name: String,
pub display_name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub icon_url: Option<String>,
#[serde(default)]
pub use_count: u64,
#[serde(default)]
pub is_deployed: bool,
/// Raw extra fields preserved for future use.
#[serde(flatten, default)]
pub extra: std::collections::HashMap<String, Value>,
}
/// Detail record returned by `GET /servers/{qualifiedName}`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SmitheryServerDetail {
pub qualified_name: String,
pub display_name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub icon_url: Option<String>,
#[serde(default)]
pub connections: Vec<SmitheryConnection>,
#[serde(flatten, default)]
pub extra: std::collections::HashMap<String, Value>,
}
/// One connection type listed on a server detail.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SmitheryConnection {
/// `"stdio"` or `"http"`.
pub r#type: String,
#[serde(default)]
pub deployment_url: Option<String>,
#[serde(default)]
pub config_schema: Option<Value>,
#[serde(default)]
pub example_config: Option<Value>,
#[serde(default)]
pub published: bool,
#[serde(flatten, default)]
pub extra: std::collections::HashMap<String, Value>,
}
/// Pagination wrapper from Smithery's `/servers` endpoint.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct SmitheryPagination {
#[serde(default)]
pub current_page: u32,
#[serde(default)]
pub page_size: u32,
#[serde(default)]
pub total_pages: u32,
#[serde(default)]
pub total_count: u64,
}
/// Full response body from `GET /servers`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SmitheryListResponse {
pub servers: Vec<SmitheryServerSummary>,
#[serde(default)]
pub pagination: SmitheryPagination,
}
// ── Chat history entry (for config_assist) ───────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatTurn {
pub role: String,
pub content: String,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn command_kind_roundtrip() {
assert_eq!(CommandKind::parse("node").as_str(), "node");
assert_eq!(CommandKind::parse("python").as_str(), "python");
assert_eq!(CommandKind::parse("binary").as_str(), "binary");
assert_eq!(CommandKind::parse("unknown").as_str(), "node");
}
#[test]
fn server_status_as_str() {
assert_eq!(ServerStatus::Connected.as_str(), "connected");
assert_eq!(ServerStatus::Disconnected.as_str(), "disconnected");
assert_eq!(ServerStatus::Connecting.as_str(), "connecting");
assert_eq!(ServerStatus::Error.as_str(), "error");
}
#[test]
fn smithery_server_summary_tolerates_missing_optional_fields() {
let raw = json!({
"qualifiedName": "@test/server",
"displayName": "Test Server"
});
let s: SmitheryServerSummary = serde_json::from_value(raw).unwrap();
assert_eq!(s.qualified_name, "@test/server");
assert!(s.description.is_none());
assert_eq!(s.use_count, 0);
assert!(!s.is_deployed);
}
#[test]
fn smithery_list_response_parses_pagination() {
let raw = json!({
"servers": [],
"pagination": {
"currentPage": 1,
"pageSize": 20,
"totalPages": 3,
"totalCount": 55
}
});
let resp: SmitheryListResponse = serde_json::from_value(raw).unwrap();
assert_eq!(resp.pagination.current_page, 1);
assert_eq!(resp.pagination.total_pages, 3);
assert_eq!(resp.pagination.total_count, 55);
}
#[test]
fn installed_server_serializes_without_env_values() {
let server = InstalledServer {
server_id: "uuid-1".to_string(),
qualified_name: "@test/server".to_string(),
display_name: "Test".to_string(),
description: None,
icon_url: None,
command_kind: CommandKind::Node,
command: "npx".to_string(),
args: vec!["-y".to_string(), "@test/server".to_string()],
env_keys: vec!["API_KEY".to_string()],
config: None,
installed_at: 1_700_000_000_000,
last_connected_at: None,
};
let v = serde_json::to_value(&server).unwrap();
// env_keys present, but no raw values
assert!(v.get("env_keys").is_some());
assert!(v.get("env_values").is_none());
}
#[test]
fn conn_status_status_field_serializes_lowercase() {
let s = ConnStatus {
server_id: "s1".to_string(),
qualified_name: "@test/s".to_string(),
display_name: "S".to_string(),
status: ServerStatus::Connected,
tool_count: 3,
last_error: None,
};
let v = serde_json::to_value(&s).unwrap();
assert_eq!(v["status"], json!("connected"));
}
}
+1
View File
@@ -45,6 +45,7 @@ pub mod integrations;
pub mod javascript;
pub mod learning;
pub mod mcp_client;
pub mod mcp_clients;
pub mod mcp_server;
pub mod meet;
pub mod meet_agent;
+79 -11
View File
@@ -44,6 +44,11 @@ pub fn get_tool(tool_id: &str) -> Result<RpcOutcome<ToolRegistryEntry>, String>
}
/// Build sorted registry entries from the current MCP and controller metadata.
///
/// This includes:
/// 1. MCP stdio server tools (existing `mcp_server` surface)
/// 2. Controller-backed tools (existing `tools` namespace)
/// 3. Connected MCP client server tools (new `mcp_clients` domain)
pub fn registry_entries() -> Vec<ToolRegistryEntry> {
let mut entries = BTreeMap::new();
@@ -57,6 +62,53 @@ pub fn registry_entries() -> Vec<ToolRegistryEntry> {
insert_registry_entry(&mut entries, entry, "controller");
}
// Enumerate tools from all currently-connected MCP client servers.
// `block_in_place` requires the multi-threaded tokio runtime; fall back
// silently to an empty list in single-threaded contexts (e.g. unit tests).
let client_tools = {
use crate::openhuman::mcp_clients::connections;
match tokio::runtime::Handle::try_current() {
Ok(handle) => {
// Only use block_in_place when we are on the multi-threaded
// runtime (kind = MultiThread). The current-thread runtime
// (kind = CurrentThread) panics on block_in_place.
if handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread {
tokio::task::block_in_place(|| {
handle.block_on(connections::all_connected_tools())
})
} else {
Vec::new()
}
}
Err(_) => Vec::new(),
}
};
for (server_id, _qualified_name_placeholder, tool) in client_tools {
let tool_id = format!("mcp-client::{server_id}::{}", tool.name);
let entry = ToolRegistryEntry {
tool_id: tool_id.clone(),
name: tool.name.clone(),
title: title_from_function(&tool.name),
description: tool.description.unwrap_or_default(),
version: REGISTRY_ENTRY_VERSION.to_string(),
transport: ToolRegistryTransport::McpStdio,
route: json!({
"protocol": "mcp-client",
"rpc_method": "openhuman.mcp_clients_tool_call",
"server_id": server_id,
"tool_name": tool.name,
}),
input_schema: tool.input_schema,
output_schema: mcp_output_schema(),
allowed_agents: vec!["*".to_string()],
tags: tags_for_tool_id(&tool_id, "mcp_client"),
enabled: true,
health: ToolRegistryHealth::Available,
};
insert_registry_entry(&mut entries, entry, "mcp_client");
}
entries.into_values().collect()
}
@@ -66,10 +118,18 @@ fn insert_registry_entry(
source: &str,
) {
let key = entry.tool_id.clone();
assert!(
entries.insert(key.clone(), entry).is_none(),
"duplicate tool_id in registry: {key} from {source}"
);
if entries.contains_key(&key) {
// Duplicate tool IDs can arrive from external MCP servers that reuse
// well-known names. First-write-wins: log and skip the duplicate
// rather than panicking or silently overwriting in production.
log::warn!(
"[tool_registry] duplicate tool_id={} from source={}; skipping",
key,
source
);
return;
}
entries.insert(key, entry);
}
fn mcp_tool_entry(spec: McpToolSpec) -> ToolRegistryEntry {
@@ -286,14 +346,13 @@ mod tests {
}
#[test]
#[should_panic(expected = "duplicate tool_id in registry")]
fn insert_registry_entry_panics_on_duplicate_tool_id() {
fn insert_registry_entry_skips_duplicate_tool_id() {
let mut entries = BTreeMap::new();
let entry = ToolRegistryEntry {
let first_entry = ToolRegistryEntry {
tool_id: "duplicate.tool".to_string(),
name: "duplicate.tool".to_string(),
title: "Duplicate Tool".to_string(),
description: "Test duplicate entry.".to_string(),
title: "First Entry".to_string(),
description: "First description.".to_string(),
version: REGISTRY_ENTRY_VERSION.to_string(),
transport: ToolRegistryTransport::JsonRpc,
route: json!({}),
@@ -304,9 +363,18 @@ mod tests {
enabled: true,
health: ToolRegistryHealth::Available,
};
let second_entry = ToolRegistryEntry {
title: "Second Entry".to_string(),
description: "Second description.".to_string(),
..first_entry.clone()
};
insert_registry_entry(&mut entries, entry.clone(), "first");
insert_registry_entry(&mut entries, entry, "second");
insert_registry_entry(&mut entries, first_entry, "first");
// Should not panic; first entry is kept, second is silently dropped.
insert_registry_entry(&mut entries, second_entry, "second");
assert_eq!(entries.len(), 1);
assert_eq!(entries["duplicate.tool"].title, "First Entry");
}
#[test]
+145
View File
@@ -6304,3 +6304,148 @@ async fn companion_session_lifecycle_over_rpc() {
mock_join.abort();
rpc_join.abort();
}
// ── MCP Clients lifecycle ─────────────────────────────────────────────────────
//
// Tests the install → installed_list → uninstall flow over real JSON-RPC.
// We do NOT test connect/tool_call here because that requires a real MCP
// server subprocess — the `FakeMcpTransport` in `client/mod.rs` covers that
// path at unit level. The spawn path is guarded behind a trait so tests can
// inject fakes without touching the filesystem.
#[tokio::test]
async fn mcp_clients_lifecycle() {
let _env_lock = json_rpc_e2e_env_lock();
let tmp = tempdir().expect("tempdir");
let home = tmp.path();
let openhuman_home = home.join(".openhuman");
let _home_guard = EnvVarGuard::set_to_path("HOME", home);
let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL");
let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL");
let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await;
let mock_origin = format!("http://{}", mock_addr);
write_min_config(&openhuman_home, &mock_origin);
let user_scoped_dir = openhuman_home.join("users").join("local");
write_min_config(&user_scoped_dir, &mock_origin);
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
let rpc_base = format!("http://{}", rpc_addr);
tokio::time::sleep(Duration::from_millis(100)).await;
// ── 1. installed_list should start empty ─────────────────────────────────
let list1 = post_json_rpc(
&rpc_base,
9901,
"openhuman.mcp_clients_installed_list",
json!({}),
)
.await;
let list1_result = assert_no_jsonrpc_error(&list1, "mcp_clients_installed_list (initial)");
// Handlers wrap their value in `{ "result": value, "logs": [...] }` when logs are
// emitted (see RpcOutcome::into_cli_compatible_json); unwrap that envelope here.
let list1_body = list1_result.get("result").unwrap_or(list1_result);
let installed = list1_body
.get("installed")
.and_then(Value::as_array)
.expect("installed_list must return an 'installed' array");
assert!(
installed.is_empty(),
"installed list should start empty: {installed:?}"
);
// ── 2. status should return empty servers ─────────────────────────────────
let status1 = post_json_rpc(&rpc_base, 9902, "openhuman.mcp_clients_status", json!({})).await;
let status1_result = assert_no_jsonrpc_error(&status1, "mcp_clients_status (initial)");
let status1_body = status1_result.get("result").unwrap_or(status1_result);
let servers = status1_body
.get("servers")
.and_then(Value::as_array)
.expect("status must return 'servers' array");
assert!(servers.is_empty(), "status should start empty: {servers:?}");
// ── 3. uninstall a non-existent server is a no-op ────────────────────────
let uninstall_missing = post_json_rpc(
&rpc_base,
9903,
"openhuman.mcp_clients_uninstall",
json!({ "server_id": "00000000-0000-0000-0000-000000000000" }),
)
.await;
// Non-existent id: may return error or removed=false — both are acceptable.
// We just verify it does not panic the server.
assert!(
uninstall_missing.get("result").is_some() || uninstall_missing.get("error").is_some(),
"uninstall missing server should return result or error: {uninstall_missing}"
);
// ── 4. registry_search (schema validation — may not have network in CI) ───
let search = post_json_rpc(
&rpc_base,
9904,
"openhuman.mcp_clients_registry_search",
json!({ "query": "test", "page": 1, "page_size": 5 }),
)
.await;
// Result or error are both acceptable; method must be registered.
assert!(
search.get("result").is_some() || search.get("error").is_some(),
"registry_search should return result or error: {search}"
);
// ── 5. connect on a non-installed server returns an error ─────────────────
let connect_missing = post_json_rpc(
&rpc_base,
9905,
"openhuman.mcp_clients_connect",
json!({ "server_id": "00000000-0000-0000-0000-000000000001" }),
)
.await;
assert!(
connect_missing.get("error").is_some(),
"connect on missing server should return error: {connect_missing}"
);
// ── 6. tool_call on a non-connected server returns is_error=true ─────────
let tool_call_disconnected = post_json_rpc(
&rpc_base,
9906,
"openhuman.mcp_clients_tool_call",
json!({
"server_id": "00000000-0000-0000-0000-000000000002",
"tool_name": "search",
"arguments": {}
}),
)
.await;
let tc_result =
assert_no_jsonrpc_error(&tool_call_disconnected, "tool_call on disconnected server");
let tc_body = tc_result.get("result").unwrap_or(tc_result);
assert_eq!(
tc_body.get("is_error"),
Some(&json!(true)),
"tool_call on disconnected server should set is_error=true: {tc_body}"
);
// ── 7. disconnect on a non-connected server is a no-op ────────────────────
let disconnect_noop = post_json_rpc(
&rpc_base,
9907,
"openhuman.mcp_clients_disconnect",
json!({ "server_id": "00000000-0000-0000-0000-000000000003" }),
)
.await;
let disc_result = assert_no_jsonrpc_error(&disconnect_noop, "disconnect noop");
let disc_body = disc_result.get("result").unwrap_or(disc_result);
assert_eq!(
disc_body.get("status").and_then(Value::as_str),
Some("disconnected"),
"disconnect noop should return status=disconnected: {disc_body}"
);
mock_join.abort();
rpc_join.abort();
}