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
This commit is contained in:
parent
1f80d7040b
commit
d15c88419c
2 changed files with 254 additions and 212 deletions
|
|
@ -245,7 +245,15 @@ const ConversationThread: React.FC<ConversationThreadProps> = ({
|
|||
className="flex items-center gap-2 text-zinc-600 hover:text-zinc-900 transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="text-sm text-zinc-500">
|
||||
{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"
|
||||
}
|
||||
})()}
|
||||
</span>
|
||||
<span className="text-lg">🔌</span>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -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<McpServerModalProps> = ({
|
|||
onClose,
|
||||
onToolsUpdate,
|
||||
}) => {
|
||||
const [serverUrl, setServerUrl] = useState(() => {
|
||||
return sessionStorage.getItem('mcpServerUrl') || ''
|
||||
const [servers, setServers] = useState<McpServer[]>(() => {
|
||||
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<any>({
|
||||
state: 'not-connected',
|
||||
tools: [],
|
||||
error: undefined,
|
||||
log: [],
|
||||
authUrl: undefined,
|
||||
retry: () => {},
|
||||
disconnect: () => {},
|
||||
authenticate: () => Promise.resolve(undefined),
|
||||
callTool: (_name: string, _args?: Record<string, unknown>) =>
|
||||
Promise.resolve(undefined),
|
||||
clearStorage: () => {},
|
||||
})
|
||||
const [connectionData, setConnectionData] = useState<Record<string, any>>({})
|
||||
const logRef = useRef<HTMLDivElement>(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<string, unknown>) =>
|
||||
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<McpServerModalProps> = ({
|
|||
}
|
||||
}, [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<string, unknown>) =>
|
||||
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<string, unknown>) =>
|
||||
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<McpServerModalProps> = ({
|
|||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden"
|
||||
className="bg-white rounded-lg shadow-xl w-full max-w-3xl max-h-[80vh] overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between p-6 border-b border-zinc-200">
|
||||
<h2 className="text-xl font-semibold text-zinc-900">MCP Servers</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-zinc-400 hover:text-zinc-600 p-1 cursor-pointer"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center">
|
||||
<Info size={16} className="text-gray-400 mr-2" />
|
||||
<span className="text-sm text-gray-600">
|
||||
Connect to Model Context Protocol (MCP) servers to access additional AI capabilities.
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="rounded-md border border-gray-200 p-1 hover:bg-gray-50"
|
||||
onClick={() => setShowSettings(!showSettings)}
|
||||
>
|
||||
<Settings size={16} className="text-gray-500" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-zinc-400 hover:text-zinc-600 p-1 cursor-pointer"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 overflow-y-auto max-h-[60vh]">
|
||||
<div className="flex items-center mb-6">
|
||||
<Info size={16} className="text-gray-400 mr-2" />
|
||||
<span className="text-sm text-gray-600">
|
||||
Connect to Model Context Protocol (MCP) servers to access additional AI capabilities.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="font-medium text-sm">Status:</label>
|
||||
{getStatusBadge()}
|
||||
</div>
|
||||
|
||||
{error && state === 'failed' && (
|
||||
<div className="text-sm text-red-600 p-3 bg-red-50 rounded border">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
className="w-full p-3 border border-gray-200 rounded-lg text-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Enter MCP server URL"
|
||||
value={serverUrl}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value
|
||||
setServerUrl(newValue)
|
||||
sessionStorage.setItem('mcpServerUrl', newValue)
|
||||
}}
|
||||
disabled={isActive && state !== 'failed'}
|
||||
/>
|
||||
{/* Server List */}
|
||||
<div className="space-y-4 mb-6">
|
||||
{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') ? (
|
||||
<button
|
||||
className="w-full px-4 py-3 bg-orange-100 hover:bg-orange-200 text-orange-900 rounded-lg text-sm font-medium"
|
||||
onClick={handleDisconnect}
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white rounded-lg py-3 px-4 text-sm font-medium disabled:opacity-50"
|
||||
onClick={handleConnect}
|
||||
disabled={isActive || !serverUrl.trim()}
|
||||
>
|
||||
Connect
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Authentication Link if needed */}
|
||||
{authUrl && (
|
||||
<div className="p-4 bg-orange-50 border border-orange-200 rounded-lg">
|
||||
<p className="text-sm mb-2">
|
||||
Authentication required. Please click the link below:
|
||||
</p>
|
||||
<a
|
||||
href={authUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-orange-700 hover:text-orange-800 underline"
|
||||
onClick={handleManualAuth}
|
||||
>
|
||||
Authenticate in new window
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tools display when connected */}
|
||||
{state === 'ready' && tools.length > 0 && (
|
||||
<div>
|
||||
<h3 className="font-medium text-sm mb-3">
|
||||
Available Tools ({tools.length})
|
||||
</h3>
|
||||
<div className="border border-gray-200 rounded-lg p-3 bg-gray-50 max-h-40 overflow-y-auto space-y-3">
|
||||
{tools.map((tool: Tool, index: number) => (
|
||||
<div
|
||||
key={index}
|
||||
className="text-sm pb-3 border-b border-gray-200 last:border-b-0"
|
||||
>
|
||||
<span className="font-medium">{tool.name}</span>
|
||||
{tool.description && (
|
||||
<p className="text-gray-600 mt-1 text-sm">
|
||||
{tool.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Debug Log */}
|
||||
{showSettings && (
|
||||
<div>
|
||||
<label className="font-medium text-sm block mb-3">Debug Log</label>
|
||||
return (
|
||||
<div
|
||||
ref={logRef}
|
||||
className="border border-gray-200 rounded-lg p-3 bg-gray-50 h-40 overflow-y-auto font-mono text-xs"
|
||||
key={server.id}
|
||||
className={`border rounded-lg p-4 ${server.enabled ? 'border-zinc-200' : 'border-zinc-100 bg-zinc-50'}`}
|
||||
>
|
||||
{log.length > 0 ? (
|
||||
log.map((entry: any, index: number) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`py-0.5 ${
|
||||
entry.level === 'debug'
|
||||
? 'text-gray-500'
|
||||
: entry.level === 'info'
|
||||
? 'text-blue-600'
|
||||
: entry.level === 'warn'
|
||||
? 'text-orange-600'
|
||||
: 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
[{entry.level}] {entry.message}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<div className="font-medium text-zinc-900">{server.name || 'MCP Server'}</div>
|
||||
<div className="text-sm text-zinc-500 break-all">{server.url}</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-gray-400">No log entries yet</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusBadge(server.enabled ? state : 'disabled')}
|
||||
<button
|
||||
onClick={() => handleToggleServer(server.id)}
|
||||
className={`p-1 rounded ${server.enabled ? 'text-green-600 hover:bg-green-50' : 'text-gray-400 hover:bg-gray-50'}`}
|
||||
title={server.enabled ? 'Disable server' : 'Enable server'}
|
||||
>
|
||||
{server.enabled ? <Power size={16} /> : <PowerOff size={16} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRemoveServer(server.id)}
|
||||
className="p-1 text-red-500 hover:bg-red-50 rounded"
|
||||
title="Delete server"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{server.enabled && (
|
||||
<>
|
||||
{error && state === 'failed' && (
|
||||
<div className="text-sm text-red-600 p-3 bg-red-50 rounded border mb-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{authUrl && (
|
||||
<div className="p-3 bg-orange-50 border border-orange-200 rounded mb-3">
|
||||
<p className="text-sm mb-2">
|
||||
Authentication required. Please click the link below:
|
||||
</p>
|
||||
<a
|
||||
href={authUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-orange-700 hover:text-orange-800 underline"
|
||||
onClick={() => handleManualAuth(server.id)}
|
||||
>
|
||||
Authenticate in new window
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'ready' && tools.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-medium text-sm mb-2">
|
||||
Available Tools ({tools.length})
|
||||
</h4>
|
||||
<div className="border border-gray-200 rounded p-2 bg-gray-50 max-h-24 overflow-y-auto space-y-1">
|
||||
{tools.map((tool: Tool, index: number) => (
|
||||
<div key={index} className="text-xs">
|
||||
<span className="font-medium">{tool.name}</span>
|
||||
{tool.description && (
|
||||
<span className="text-gray-600 ml-2">- {tool.description}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{connectionData?.state !== 'not-connected' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
connectionData?.clearStorage()
|
||||
if (isActive) {
|
||||
handleDisconnect()
|
||||
}
|
||||
}}
|
||||
className="text-sm text-orange-600 hover:text-orange-800 hover:underline mt-2"
|
||||
>
|
||||
Clear stored authentication
|
||||
</button>
|
||||
)}
|
||||
)
|
||||
})}
|
||||
|
||||
{servers.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Info size={24} className="mx-auto mb-2" />
|
||||
<p>No MCP servers configured yet.</p>
|
||||
<p className="text-sm">Add your first server below.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add New Server */}
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="font-medium text-sm mb-3">Add New Server</h3>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 p-3 border border-gray-200 rounded-lg text-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Enter MCP server URL"
|
||||
value={newServerUrl}
|
||||
onChange={(e) => setNewServerUrl(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleAddServer()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="px-4 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium disabled:opacity-50 flex items-center gap-2"
|
||||
onClick={handleAddServer}
|
||||
disabled={!newServerUrl.trim()}
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Debug Info */}
|
||||
{showSettings && (
|
||||
<div className="border-t pt-4 mt-4">
|
||||
<h3 className="font-medium text-sm mb-3">Debug Information</h3>
|
||||
<div className="text-xs space-y-2">
|
||||
<div>Total Servers: {servers.length}</div>
|
||||
<div>Enabled Servers: {servers.filter(s => s.enabled).length}</div>
|
||||
<div>Connected Servers: {Object.values(connectionData).filter((c: any) => c.state === 'ready').length}</div>
|
||||
<div>Total Tools: {Object.values(connectionData).reduce((sum: number, c: any) => sum + (c.tools?.length || 0), 0)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Only render the actual MCP connection when active */}
|
||||
{isActive && (
|
||||
{/* Render MCP connections for all enabled servers */}
|
||||
{servers.filter(s => s.enabled).map(server => (
|
||||
<McpConnection
|
||||
serverUrl={serverUrl}
|
||||
onConnectionUpdate={setConnectionData}
|
||||
key={server.id}
|
||||
server={server}
|
||||
onConnectionUpdate={handleConnectionUpdate}
|
||||
/>
|
||||
)}
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue