mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(mcp-clients): MCP client subsystem with Smithery registry + UI (#2409)
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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,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';
|
||||
|
||||
|
||||
@@ -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."),
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(¬if).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(¬if).await;
|
||||
let _ = proc.child.kill().await;
|
||||
}
|
||||
}
|
||||
|
||||
// ── FakeMcpTransport (test double) ──────────────────────────────────────────
|
||||
|
||||
/// An in-memory fake for `McpTransport` usable in unit and E2E tests.
|
||||
/// Responds to `initialize`, `list_tools`, and `call_tool` without spawning
|
||||
/// any real process.
|
||||
pub struct FakeMcpTransport {
|
||||
pub tools: Vec<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"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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(¶ms, "query")?;
|
||||
let page = read_optional_u32(¶ms, "page")?;
|
||||
let page_size = read_optional_u32(¶ms, "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>(¶ms, "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>(¶ms, "qualified_name")?;
|
||||
let env = read_required::<std::collections::HashMap<String, String>>(¶ms, "env")?;
|
||||
let config_value = read_optional_json(¶ms, "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>(¶ms, "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>(¶ms, "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>(¶ms, "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>(¶ms, "server_id")?;
|
||||
let tool_name = read_required::<String>(¶ms, "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>(¶ms, "qualified_name")?;
|
||||
let user_message = read_required::<String>(¶ms, "user_message")?;
|
||||
let history = read_optional::<Vec<crate::openhuman::mcp_clients::types::ChatTurn>>(
|
||||
¶ms, "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(¶ms, "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");
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user