Skip to content

Commit c204a53

Browse files
authored
feat(mcp-clients): MCP client subsystem with Smithery registry + UI (tinyhumansai#2409)
1 parent 6281aea commit c204a53

40 files changed

Lines changed: 6775 additions & 14 deletions

app/src/components/channels/ChannelConfigPanel.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { ChannelDefinition, ChannelType } from '../../types/channels';
22
import ChannelCapabilities from './ChannelCapabilities';
33
import DiscordConfig from './DiscordConfig';
4+
import McpServersTab from './mcp/McpServersTab';
45
import TelegramConfig from './TelegramConfig';
56
import WebChannelConfig from './WebChannelConfig';
67

@@ -10,6 +11,25 @@ interface ChannelConfigPanelProps {
1011
}
1112

1213
const ChannelConfigPanel = ({ selectedChannel, definitions }: ChannelConfigPanelProps) => {
14+
// MCP is a virtual tab — not backed by a ChannelDefinition from the core.
15+
if (selectedChannel === 'mcp') {
16+
return (
17+
<div className="space-y-4">
18+
<section className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 space-y-3">
19+
<div>
20+
<h3 className="text-base font-semibold text-stone-900 dark:text-neutral-100">
21+
MCP Servers
22+
</h3>
23+
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-1">
24+
Browse and manage Model Context Protocol servers that extend the AI with new tools.
25+
</p>
26+
</div>
27+
<McpServersTab />
28+
</section>
29+
</div>
30+
);
31+
}
32+
1333
const definition = definitions.find(d => d.id === selectedChannel);
1434
if (!definition) return null;
1535

app/src/components/channels/ChannelSelector.tsx

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,17 @@ interface ChannelSelectorProps {
1212
onSelectChannel: (channel: ChannelType) => void;
1313
}
1414

15-
const CHANNEL_ICONS: Record<string, string> = { telegram: '✈️', discord: '🎮', web: '🌐' };
15+
const CHANNEL_ICONS: Record<string, string> = {
16+
telegram: '✈️',
17+
discord: '🎮',
18+
web: '🌐',
19+
mcp: '🔌',
20+
};
21+
22+
/** Virtual (static) tabs that are not backed by a ChannelDefinition from the core. */
23+
const VIRTUAL_TABS: { id: ChannelType; display_name: string }[] = [
24+
{ id: 'mcp', display_name: 'MCP Servers' },
25+
];
1626
const CHANNEL_STATUS_PRIORITY: ChannelConnectionStatus[] = [
1727
'connected',
1828
'connecting',
@@ -48,7 +58,7 @@ const ChannelSelector = ({
4858
</p>
4959
</div>
5060

51-
<div className="flex gap-2">
61+
<div className="flex gap-2 flex-wrap">
5262
{definitions.map(def => {
5363
const channelId = def.id as ChannelType;
5464
const isSelected = selectedChannel === channelId;
@@ -81,6 +91,25 @@ const ChannelSelector = ({
8191
</button>
8292
);
8393
})}
94+
95+
{/* Virtual tabs — not backed by a ChannelDefinition from the core */}
96+
{VIRTUAL_TABS.map(tab => {
97+
const isSelected = selectedChannel === tab.id;
98+
return (
99+
<button
100+
key={tab.id}
101+
type="button"
102+
onClick={() => onSelectChannel(tab.id)}
103+
className={`flex-1 flex items-center gap-2 rounded-lg border px-4 py-3 text-sm transition-colors ${
104+
isSelected
105+
? 'border-primary-500/60 bg-primary-50 dark:bg-primary-500/15 text-primary-600 dark:text-primary-300'
106+
: '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'
107+
}`}>
108+
<span className="text-base">{CHANNEL_ICONS[tab.id] ?? ''}</span>
109+
<span className="font-medium">{tab.display_name}</span>
110+
</button>
111+
);
112+
})}
84113
</div>
85114
</section>
86115
);
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
2+
import { beforeEach, describe, expect, it, vi } from 'vitest';
3+
4+
import ConfigAssistantPanel from './ConfigAssistantPanel';
5+
6+
const mockConfigAssist = vi.fn();
7+
8+
vi.mock('../../../services/api/mcpClientsApi', () => ({
9+
mcpClientsApi: { configAssist: (...args: unknown[]) => mockConfigAssist(...args) },
10+
}));
11+
12+
describe('ConfigAssistantPanel', () => {
13+
beforeEach(() => {
14+
mockConfigAssist.mockReset();
15+
});
16+
17+
it('renders the input textarea and Send button', () => {
18+
render(<ConfigAssistantPanel qualifiedName="acme/test" />);
19+
expect(screen.getByPlaceholderText(/ask a question/i)).toBeInTheDocument();
20+
expect(screen.getByRole('button', { name: 'Send' })).toBeInTheDocument();
21+
});
22+
23+
it('send button is disabled when input is empty', () => {
24+
render(<ConfigAssistantPanel qualifiedName="acme/test" />);
25+
expect(screen.getByRole('button', { name: 'Send' })).toBeDisabled();
26+
});
27+
28+
it('enables send button when input has text', () => {
29+
render(<ConfigAssistantPanel qualifiedName="acme/test" />);
30+
fireEvent.change(screen.getByPlaceholderText(/ask a question/i), {
31+
target: { value: 'What env vars do I need?' },
32+
});
33+
expect(screen.getByRole('button', { name: 'Send' })).not.toBeDisabled();
34+
});
35+
36+
it('sends message and renders assistant reply', async () => {
37+
mockConfigAssist.mockResolvedValue({ reply: 'You need an API_KEY env var.' });
38+
render(<ConfigAssistantPanel qualifiedName="acme/test" />);
39+
40+
fireEvent.change(screen.getByPlaceholderText(/ask a question/i), {
41+
target: { value: 'What do I need?' },
42+
});
43+
44+
await act(async () => {
45+
fireEvent.click(screen.getByRole('button', { name: 'Send' }));
46+
});
47+
48+
await waitFor(() => {
49+
expect(screen.getByText('You need an API_KEY env var.')).toBeInTheDocument();
50+
});
51+
52+
expect(mockConfigAssist).toHaveBeenCalledWith({
53+
qualified_name: 'acme/test',
54+
user_message: 'What do I need?',
55+
history: [{ role: 'user', content: 'What do I need?' }],
56+
});
57+
});
58+
59+
it('shows suggested_env values and Apply button', async () => {
60+
mockConfigAssist.mockResolvedValue({
61+
reply: 'Here are suggested values',
62+
suggested_env: { API_KEY: 'abc123' },
63+
});
64+
65+
const onApply = vi.fn();
66+
render(<ConfigAssistantPanel qualifiedName="acme/test" onApplySuggestedEnv={onApply} />);
67+
68+
fireEvent.change(screen.getByPlaceholderText(/ask a question/i), {
69+
target: { value: 'Help me configure' },
70+
});
71+
72+
await act(async () => {
73+
fireEvent.click(screen.getByRole('button', { name: 'Send' }));
74+
});
75+
76+
await waitFor(() => {
77+
expect(screen.getByText('Here are suggested values')).toBeInTheDocument();
78+
});
79+
80+
// Shows key name (the colon is in the same text node with whitespace)
81+
expect(screen.getByText(/API_KEY:/)).toBeInTheDocument();
82+
83+
// Apply button exists and calls the callback
84+
const applyBtn = screen.getByRole('button', { name: 'Apply suggested values' });
85+
fireEvent.click(applyBtn);
86+
expect(onApply).toHaveBeenCalledWith({ API_KEY: 'abc123' });
87+
});
88+
89+
it('shows error on failed request', async () => {
90+
mockConfigAssist.mockRejectedValue(new Error('AI service unavailable'));
91+
render(<ConfigAssistantPanel qualifiedName="acme/test" />);
92+
93+
fireEvent.change(screen.getByPlaceholderText(/ask a question/i), {
94+
target: { value: 'Hello' },
95+
});
96+
97+
await act(async () => {
98+
fireEvent.click(screen.getByRole('button', { name: 'Send' }));
99+
});
100+
101+
await waitFor(() => {
102+
expect(screen.getByText('AI service unavailable')).toBeInTheDocument();
103+
});
104+
});
105+
106+
it('clears input after sending', async () => {
107+
mockConfigAssist.mockResolvedValue({ reply: 'OK' });
108+
render(<ConfigAssistantPanel qualifiedName="acme/test" />);
109+
110+
const textarea = screen.getByPlaceholderText(/ask a question/i) as HTMLTextAreaElement;
111+
fireEvent.change(textarea, { target: { value: 'Question?' } });
112+
113+
await act(async () => {
114+
fireEvent.click(screen.getByRole('button', { name: 'Send' }));
115+
});
116+
117+
expect(textarea.value).toBe('');
118+
});
119+
120+
it('sends on Enter key press', async () => {
121+
mockConfigAssist.mockResolvedValue({ reply: 'reply' });
122+
render(<ConfigAssistantPanel qualifiedName="acme/test" />);
123+
124+
const textarea = screen.getByPlaceholderText(/ask a question/i);
125+
fireEvent.change(textarea, { target: { value: 'test message' } });
126+
127+
await act(async () => {
128+
fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false });
129+
});
130+
131+
await waitFor(() => {
132+
expect(mockConfigAssist).toHaveBeenCalledTimes(1);
133+
});
134+
});
135+
});

0 commit comments

Comments
 (0)