diff --git a/app/src/components/channels/ChannelConfigPanel.tsx b/app/src/components/channels/ChannelConfigPanel.tsx index 826d2922a..c268ead1a 100644 --- a/app/src/components/channels/ChannelConfigPanel.tsx +++ b/app/src/components/channels/ChannelConfigPanel.tsx @@ -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 ( +
+
+
+

+ MCP Servers +

+

+ Browse and manage Model Context Protocol servers that extend the AI with new tools. +

+
+ +
+
+ ); + } + const definition = definitions.find(d => d.id === selectedChannel); if (!definition) return null; diff --git a/app/src/components/channels/ChannelSelector.tsx b/app/src/components/channels/ChannelSelector.tsx index b4b7ee804..6e41f598a 100644 --- a/app/src/components/channels/ChannelSelector.tsx +++ b/app/src/components/channels/ChannelSelector.tsx @@ -12,7 +12,17 @@ interface ChannelSelectorProps { onSelectChannel: (channel: ChannelType) => void; } -const CHANNEL_ICONS: Record = { telegram: '✈️', discord: '🎮', web: '🌐' }; +const CHANNEL_ICONS: Record = { + 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 = ({

-
+
{definitions.map(def => { const channelId = def.id as ChannelType; const isSelected = selectedChannel === channelId; @@ -81,6 +91,25 @@ const ChannelSelector = ({ ); })} + + {/* Virtual tabs — not backed by a ChannelDefinition from the core */} + {VIRTUAL_TABS.map(tab => { + const isSelected = selectedChannel === tab.id; + return ( + + ); + })}
); diff --git a/app/src/components/channels/mcp/ConfigAssistantPanel.test.tsx b/app/src/components/channels/mcp/ConfigAssistantPanel.test.tsx new file mode 100644 index 000000000..11e28a087 --- /dev/null +++ b/app/src/components/channels/mcp/ConfigAssistantPanel.test.tsx @@ -0,0 +1,135 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import ConfigAssistantPanel from './ConfigAssistantPanel'; + +const mockConfigAssist = vi.fn(); + +vi.mock('../../../services/api/mcpClientsApi', () => ({ + mcpClientsApi: { configAssist: (...args: unknown[]) => mockConfigAssist(...args) }, +})); + +describe('ConfigAssistantPanel', () => { + beforeEach(() => { + mockConfigAssist.mockReset(); + }); + + it('renders the input textarea and Send button', () => { + render(); + expect(screen.getByPlaceholderText(/ask a question/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Send' })).toBeInTheDocument(); + }); + + it('send button is disabled when input is empty', () => { + render(); + expect(screen.getByRole('button', { name: 'Send' })).toBeDisabled(); + }); + + it('enables send button when input has text', () => { + render(); + fireEvent.change(screen.getByPlaceholderText(/ask a question/i), { + target: { value: 'What env vars do I need?' }, + }); + expect(screen.getByRole('button', { name: 'Send' })).not.toBeDisabled(); + }); + + it('sends message and renders assistant reply', async () => { + mockConfigAssist.mockResolvedValue({ reply: 'You need an API_KEY env var.' }); + render(); + + fireEvent.change(screen.getByPlaceholderText(/ask a question/i), { + target: { value: 'What do I need?' }, + }); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Send' })); + }); + + await waitFor(() => { + expect(screen.getByText('You need an API_KEY env var.')).toBeInTheDocument(); + }); + + expect(mockConfigAssist).toHaveBeenCalledWith({ + qualified_name: 'acme/test', + user_message: 'What do I need?', + history: [{ role: 'user', content: 'What do I need?' }], + }); + }); + + it('shows suggested_env values and Apply button', async () => { + mockConfigAssist.mockResolvedValue({ + reply: 'Here are suggested values', + suggested_env: { API_KEY: 'abc123' }, + }); + + const onApply = vi.fn(); + render(); + + fireEvent.change(screen.getByPlaceholderText(/ask a question/i), { + target: { value: 'Help me configure' }, + }); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Send' })); + }); + + await waitFor(() => { + expect(screen.getByText('Here are suggested values')).toBeInTheDocument(); + }); + + // Shows key name (the colon is in the same text node with whitespace) + expect(screen.getByText(/API_KEY:/)).toBeInTheDocument(); + + // Apply button exists and calls the callback + const applyBtn = screen.getByRole('button', { name: 'Apply suggested values' }); + fireEvent.click(applyBtn); + expect(onApply).toHaveBeenCalledWith({ API_KEY: 'abc123' }); + }); + + it('shows error on failed request', async () => { + mockConfigAssist.mockRejectedValue(new Error('AI service unavailable')); + render(); + + fireEvent.change(screen.getByPlaceholderText(/ask a question/i), { + target: { value: 'Hello' }, + }); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Send' })); + }); + + await waitFor(() => { + expect(screen.getByText('AI service unavailable')).toBeInTheDocument(); + }); + }); + + it('clears input after sending', async () => { + mockConfigAssist.mockResolvedValue({ reply: 'OK' }); + render(); + + const textarea = screen.getByPlaceholderText(/ask a question/i) as HTMLTextAreaElement; + fireEvent.change(textarea, { target: { value: 'Question?' } }); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Send' })); + }); + + expect(textarea.value).toBe(''); + }); + + it('sends on Enter key press', async () => { + mockConfigAssist.mockResolvedValue({ reply: 'reply' }); + render(); + + const textarea = screen.getByPlaceholderText(/ask a question/i); + fireEvent.change(textarea, { target: { value: 'test message' } }); + + await act(async () => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + + await waitFor(() => { + expect(mockConfigAssist).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/app/src/components/channels/mcp/ConfigAssistantPanel.tsx b/app/src/components/channels/mcp/ConfigAssistantPanel.tsx new file mode 100644 index 000000000..c52243a40 --- /dev/null +++ b/app/src/components/channels/mcp/ConfigAssistantPanel.tsx @@ -0,0 +1,181 @@ +/** + * Inline LLM-driven configuration assistant chat. + * Maintains a local message history and calls config_assist with each send. + * If the reply includes suggested_env, shows an "Apply suggested values" button + * that passes them up to the caller (e.g. to pre-fill the install dialog). + */ +import debug from 'debug'; +import { useCallback, useRef, useState } from 'react'; + +import { mcpClientsApi } from '../../../services/api/mcpClientsApi'; + +const log = debug('mcp-clients:config-assist'); + +interface Message { + role: 'user' | 'assistant'; + content: string; + suggested_env?: Record; +} + +interface ConfigAssistantPanelProps { + qualifiedName: string; + onApplySuggestedEnv?: (env: Record) => void; +} + +const ConfigAssistantPanel = ({ + qualifiedName, + onApplySuggestedEnv, +}: ConfigAssistantPanelProps) => { + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(''); + const [sending, setSending] = useState(false); + const [error, setError] = useState(null); + const bottomRef = useRef(null); + + const scrollToBottom = useCallback(() => { + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, []); + + const handleSend = useCallback(async () => { + const text = input.trim(); + if (!text || sending) return; + + const userMessage: Message = { role: 'user', content: text }; + const updatedHistory = [...messages, userMessage]; + setMessages(updatedHistory); + setInput(''); + setSending(true); + setError(null); + log('sending message: %s', text); + + try { + const result = await mcpClientsApi.configAssist({ + qualified_name: qualifiedName, + user_message: text, + history: updatedHistory.map(m => ({ role: m.role, content: m.content })), + }); + log( + 'received reply length=%d suggested_env=%s', + result.reply.length, + result.suggested_env ? 'yes' : 'no' + ); + + const assistantMessage: Message = { + role: 'assistant', + content: result.reply, + suggested_env: result.suggested_env, + }; + setMessages(prev => [...prev, assistantMessage]); + setTimeout(scrollToBottom, 50); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Failed to get response'; + log('config_assist error: %s', msg); + setError(msg); + } finally { + setSending(false); + } + }, [input, messages, qualifiedName, sending, scrollToBottom]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + void handleSend(); + } + }, + [handleSend] + ); + + return ( +
+

+ Configuration assistant +

+ + {/* Message list */} +
+ {messages.length === 0 && ( +

+ Ask about configuration, required env vars, or setup steps. +

+ )} + {messages.map((msg, idx) => ( +
+
+

{msg.content}

+ {msg.suggested_env && Object.keys(msg.suggested_env).length > 0 && ( +
+

Suggested values:

+
    + {Object.keys(msg.suggested_env).map(key => ( +
  • + {key}: (value hidden) +
  • + ))} +
+ {onApplySuggestedEnv && ( + + )} + {!onApplySuggestedEnv && ( +

+ Re-install with these values to apply them. +

+ )} +
+ )} +
+
+ ))} + {sending && ( +
+
+ Thinking... +
+
+ )} +
+
+ + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Input row */} +
+