From d15c88419cd0d24ac674f0d11c755138459dab81 Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Mon, 23 Jun 2025 16:52:14 +1000 Subject: [PATCH] Implement multiple MCP servers support - Completely rewrite McpServerModal to support multiple servers - Add McpServer interface with id, url, enabled, and name fields - Use localStorage 'mcpServers' array instead of single server URL - Add server list UI with status badges, toggle, and delete buttons - Add 'Add New Server' section at bottom with input and Add button - Support enabling/disabling individual servers with Power/PowerOff icons - Delete servers with Trash2 icon in top right of each server block - Aggregate tools from all enabled servers for parent component - Render multiple McpConnection components for enabled servers - Update plug indicator to show correct server count - Add debug information panel showing server and tool counts - Handle server names using hostname from URL - Each server gets unique connection state tracking by server ID --- .../src/components/ConversationThread.tsx | 10 +- .../chat-ui/src/components/McpServerModal.tsx | 462 ++++++++++-------- 2 files changed, 257 insertions(+), 215 deletions(-) diff --git a/examples/chat-ui/src/components/ConversationThread.tsx b/examples/chat-ui/src/components/ConversationThread.tsx index 0d3bb46..d6490ca 100644 --- a/examples/chat-ui/src/components/ConversationThread.tsx +++ b/examples/chat-ui/src/components/ConversationThread.tsx @@ -245,7 +245,15 @@ const ConversationThread: React.FC = ({ className="flex items-center gap-2 text-zinc-600 hover:text-zinc-900 transition-colors cursor-pointer" > - {mcpTools.length > 0 ? `1 (${mcpTools.length})` : "0"} + {(() => { + try { + const servers = JSON.parse(localStorage.getItem('mcpServers') || '[]') + const connectedServers = servers.filter((s: any) => s.enabled).length + return mcpTools.length > 0 ? `${connectedServers} (${mcpTools.length})` : connectedServers.toString() + } catch { + return mcpTools.length > 0 ? `1 (${mcpTools.length})` : "0" + } + })()} 🔌 diff --git a/examples/chat-ui/src/components/McpServerModal.tsx b/examples/chat-ui/src/components/McpServerModal.tsx index a70118b..96cf8db 100644 --- a/examples/chat-ui/src/components/McpServerModal.tsx +++ b/examples/chat-ui/src/components/McpServerModal.tsx @@ -1,18 +1,25 @@ import React, { useState, useRef, useEffect } from 'react' -import { X, Info, Settings } from 'lucide-react' +import { X, Info, Settings, Plus, Trash2, Power, PowerOff } from 'lucide-react' import { useMcp, type Tool } from 'use-mcp/react' -// MCP Connection wrapper that only renders when active +interface McpServer { + id: string + url: string + enabled: boolean + name?: string +} + +// MCP Connection wrapper for a single server function McpConnection({ - serverUrl, + server, onConnectionUpdate, }: { - serverUrl: string - onConnectionUpdate: (data: any) => void + server: McpServer + onConnectionUpdate: (serverId: string, data: any) => void }) { // Use the MCP hook with the server URL const connection = useMcp({ - url: serverUrl, + url: server.url, debug: true, autoRetry: false, popupFeatures: 'width=500,height=600,resizable=yes,scrollbars=yes', @@ -20,8 +27,9 @@ function McpConnection({ // Update parent component with connection data useEffect(() => { - onConnectionUpdate(connection) + onConnectionUpdate(server.id, connection) }, [ + server.id, connection.state, connection.tools, connection.error, @@ -44,42 +52,19 @@ const McpServerModal: React.FC = ({ onClose, onToolsUpdate, }) => { - const [serverUrl, setServerUrl] = useState(() => { - return sessionStorage.getItem('mcpServerUrl') || '' + const [servers, setServers] = useState(() => { + const stored = localStorage.getItem('mcpServers') + return stored ? JSON.parse(stored) : [] }) - const [isActive, setIsActive] = useState(false) + const [newServerUrl, setNewServerUrl] = useState('') const [showSettings, setShowSettings] = useState(false) - const [connectionData, setConnectionData] = useState({ - state: 'not-connected', - tools: [], - error: undefined, - log: [], - authUrl: undefined, - retry: () => {}, - disconnect: () => {}, - authenticate: () => Promise.resolve(undefined), - callTool: (_name: string, _args?: Record) => - Promise.resolve(undefined), - clearStorage: () => {}, - }) + const [connectionData, setConnectionData] = useState>({}) const logRef = useRef(null) - // Extract connection properties - const { state, tools, error, log, authUrl, disconnect, authenticate } = - connectionData - - // Notify parent component when tools change + // Save servers to localStorage whenever they change useEffect(() => { - if (onToolsUpdate && tools.length > 0) { - onToolsUpdate( - tools.map((t: Tool) => ({ - ...t, - callTool: (args: Record) => - connectionData.callTool(t.name, args), - })), - ) - } - }, [tools, onToolsUpdate]) + localStorage.setItem('mcpServers', JSON.stringify(servers)) + }, [servers]) useEffect(() => { if (isOpen) { @@ -93,49 +78,94 @@ const McpServerModal: React.FC = ({ } }, [isOpen]) - // Handle connection - const handleConnect = () => { - if (!serverUrl.trim()) return - setIsActive(true) + // Aggregate all tools from enabled servers and notify parent + useEffect(() => { + const allTools: Tool[] = [] + + servers.forEach(server => { + if (server.enabled && connectionData[server.id]?.tools) { + const serverTools = connectionData[server.id].tools.map((t: Tool) => ({ + ...t, + callTool: (args: Record) => + connectionData[server.id].callTool(t.name, args), + })) + allTools.push(...serverTools) + } + }) + + if (onToolsUpdate) { + onToolsUpdate(allTools) + } + }, [servers, connectionData, onToolsUpdate]) + + // Handle adding a new server + const handleAddServer = () => { + if (!newServerUrl.trim()) return + + const newServer: McpServer = { + id: Date.now().toString(), + url: newServerUrl.trim(), + enabled: true, + name: new URL(newServerUrl.trim()).hostname + } + + setServers(prev => [...prev, newServer]) + setNewServerUrl('') } - // Handle disconnection - const handleDisconnect = () => { - disconnect() - setIsActive(false) - setConnectionData({ - state: 'not-connected', - tools: [], - error: undefined, - log: [], - authUrl: undefined, - retry: () => {}, - disconnect: () => {}, - authenticate: () => Promise.resolve(undefined), - callTool: (_name: string, _args?: Record) => - Promise.resolve(undefined), - clearStorage: () => {}, + // Handle removing a server + const handleRemoveServer = (serverId: string) => { + // Disconnect if connected + const connection = connectionData[serverId] + if (connection?.disconnect) { + connection.disconnect() + } + + setServers(prev => prev.filter(s => s.id !== serverId)) + setConnectionData(prev => { + const newData = { ...prev } + delete newData[serverId] + return newData }) } - // Handle authentication if popup was blocked - const handleManualAuth = async () => { + // Handle toggling server enabled state + const handleToggleServer = (serverId: string) => { + setServers(prev => prev.map(server => + server.id === serverId + ? { ...server, enabled: !server.enabled } + : server + )) + + // If disabling, disconnect + const server = servers.find(s => s.id === serverId) + if (server?.enabled && connectionData[serverId]?.disconnect) { + connectionData[serverId].disconnect() + } + } + + // Handle connection update for a specific server + const handleConnectionUpdate = (serverId: string, data: any) => { + setConnectionData(prev => ({ + ...prev, + [serverId]: data + })) + } + + // Handle authentication for a specific server + const handleManualAuth = async (serverId: string) => { try { - await authenticate() + const connection = connectionData[serverId] + if (connection?.authenticate) { + await connection.authenticate() + } } catch (err) { console.error('Authentication error:', err) } } - // Auto-scroll log to bottom - useEffect(() => { - if (logRef.current) { - logRef.current.scrollTop = logRef.current.scrollHeight - } - }, [log]) - // Generate status badge based on connection state - const getStatusBadge = () => { + const getStatusBadge = (state: string) => { const baseClasses = 'px-2 py-1 rounded-full text-xs font-medium' switch (state) { @@ -192,178 +222,182 @@ const McpServerModal: React.FC = ({ onClick={onClose} >
e.stopPropagation()} >

MCP Servers

- -
- -
-
-
- - - Connect to Model Context Protocol (MCP) servers to access additional AI capabilities. - -
+
+ +
+
+ +
+
+ + + Connect to Model Context Protocol (MCP) servers to access additional AI capabilities. +
-
-
- - {getStatusBadge()} -
- - {error && state === 'failed' && ( -
- {error} -
- )} - -
- { - const newValue = e.target.value - setServerUrl(newValue) - sessionStorage.setItem('mcpServerUrl', newValue) - }} - disabled={isActive && state !== 'failed'} - /> + {/* Server List */} +
+ {servers.map((server) => { + const connection = connectionData[server.id] || { state: 'not-connected', tools: [], error: undefined, authUrl: undefined } + const { state, tools, error, authUrl } = connection - {state === 'ready' || - (isActive && state !== 'not-connected' && state !== 'failed') ? ( - - ) : ( - - )} -
- - {/* Authentication Link if needed */} - {authUrl && ( -
-

- Authentication required. Please click the link below: -

- - Authenticate in new window - -
- )} - - {/* Tools display when connected */} - {state === 'ready' && tools.length > 0 && ( -
-

- Available Tools ({tools.length}) -

-
- {tools.map((tool: Tool, index: number) => ( -
- {tool.name} - {tool.description && ( -

- {tool.description} -

- )} -
- ))} -
-
- )} - - {/* Debug Log */} - {showSettings && ( -
- + return (
- {log.length > 0 ? ( - log.map((entry: any, index: number) => ( -
- [{entry.level}] {entry.message} +
+
+
+
{server.name || 'MCP Server'}
+
{server.url}
- )) - ) : ( -
No log entries yet
+
+ +
+ {getStatusBadge(server.enabled ? state : 'disabled')} + + +
+
+ + {server.enabled && ( + <> + {error && state === 'failed' && ( +
+ {error} +
+ )} + + {authUrl && ( +
+

+ Authentication required. Please click the link below: +

+ handleManualAuth(server.id)} + > + Authenticate in new window + +
+ )} + + {state === 'ready' && tools.length > 0 && ( +
+

+ Available Tools ({tools.length}) +

+
+ {tools.map((tool: Tool, index: number) => ( +
+ {tool.name} + {tool.description && ( + - {tool.description} + )} +
+ ))} +
+
+ )} + )}
- {connectionData?.state !== 'not-connected' && ( - - )} + ) + })} + + {servers.length === 0 && ( +
+ +

No MCP servers configured yet.

+

Add your first server below.

)}
+ + {/* Add New Server */} +
+

Add New Server

+
+ setNewServerUrl(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + handleAddServer() + } + }} + /> + +
+
+ + {/* Debug Info */} + {showSettings && ( +
+

Debug Information

+
+
Total Servers: {servers.length}
+
Enabled Servers: {servers.filter(s => s.enabled).length}
+
Connected Servers: {Object.values(connectionData).filter((c: any) => c.state === 'ready').length}
+
Total Tools: {Object.values(connectionData).reduce((sum: number, c: any) => sum + (c.tools?.length || 0), 0)}
+
+
+ )}
- {/* Only render the actual MCP connection when active */} - {isActive && ( + {/* Render MCP connections for all enabled servers */} + {servers.filter(s => s.enabled).map(server => ( - )} + ))} ) }