Spaces:
Running
Running
import React, { useState } from "react"; | |
import { discoverOAuthEndpoints, startOAuthFlow } from "../services/oauth"; | |
import { Plus, Server, Wifi, WifiOff, Trash2, TestTube } from "lucide-react"; | |
import { useMCP } from "../hooks/useMCP"; | |
import type { MCPServerConfig } from "../types/mcp"; | |
import { STORAGE_KEYS, DEFAULTS } from "../config/constants"; | |
interface MCPServerManagerProps { | |
isOpen: boolean; | |
onClose: () => void; | |
} | |
export const MCPServerManager: React.FC<MCPServerManagerProps> = ({ | |
isOpen, | |
onClose, | |
}) => { | |
const { | |
mcpState, | |
addServer, | |
removeServer, | |
connectToServer, | |
disconnectFromServer, | |
testConnection, | |
} = useMCP(); | |
const [showAddForm, setShowAddForm] = useState(false); | |
const [testingConnection, setTestingConnection] = useState<string | null>( | |
null | |
); | |
const [notification, setNotification] = useState<{ | |
message: string; | |
type: 'success' | 'error'; | |
} | null>(null); | |
const [newServer, setNewServer] = useState<Omit<MCPServerConfig, "id">>({ | |
name: "", | |
url: "", | |
enabled: true, | |
transport: "streamable-http", | |
auth: { | |
type: "bearer", | |
}, | |
}); | |
if (!isOpen) return null; | |
const handleAddServer = async () => { | |
if (!newServer.name || !newServer.url) return; | |
const serverConfig: MCPServerConfig = { | |
...newServer, | |
id: `server_${Date.now()}`, | |
}; | |
// Persist name and transport for OAuth flow | |
localStorage.setItem(STORAGE_KEYS.MCP_SERVER_NAME, newServer.name); | |
localStorage.setItem(STORAGE_KEYS.MCP_SERVER_TRANSPORT, newServer.transport); | |
try { | |
await addServer(serverConfig); | |
setNewServer({ | |
name: "", | |
url: "", | |
enabled: true, | |
transport: "streamable-http", | |
auth: { | |
type: "bearer", | |
}, | |
}); | |
setShowAddForm(false); | |
} catch (error) { | |
setNotification({ | |
message: `Failed to add server: ${error instanceof Error ? error.message : 'Unknown error'}`, | |
type: 'error' | |
}); | |
setTimeout(() => setNotification(null), DEFAULTS.OAUTH_ERROR_TIMEOUT); | |
} | |
}; | |
const handleTestConnection = async (config: MCPServerConfig) => { | |
setTestingConnection(config.id); | |
try { | |
const success = await testConnection(config); | |
if (success) { | |
setNotification({ message: "Connection test successful!", type: 'success' }); | |
} else { | |
setNotification({ message: "Connection test failed. Please check your configuration.", type: 'error' }); | |
} | |
} catch (error) { | |
setNotification({ message: `Connection test failed: ${error}`, type: 'error' }); | |
} finally { | |
setTestingConnection(null); | |
// Auto-hide notification after 3 seconds | |
setTimeout(() => setNotification(null), DEFAULTS.NOTIFICATION_TIMEOUT); | |
} | |
}; | |
const handleToggleConnection = async ( | |
serverId: string, | |
isConnected: boolean | |
) => { | |
try { | |
if (isConnected) { | |
await disconnectFromServer(serverId); | |
} else { | |
await connectToServer(serverId); | |
} | |
} catch (error) { | |
setNotification({ | |
message: `Failed to toggle connection: ${error instanceof Error ? error.message : 'Unknown error'}`, | |
type: 'error' | |
}); | |
setTimeout(() => setNotification(null), DEFAULTS.OAUTH_ERROR_TIMEOUT); | |
} | |
}; | |
return ( | |
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> | |
<div className="bg-gray-800 rounded-lg p-6 w-full max-w-4xl max-h-[80vh] overflow-y-auto"> | |
<div className="flex justify-between items-center mb-6"> | |
<h2 className="text-2xl font-bold text-white flex items-center gap-2"> | |
<Server className="text-blue-400" /> | |
MCP Server Manager | |
</h2> | |
<button onClick={onClose} className="text-gray-400 hover:text-white"> | |
✕ | |
</button> | |
</div> | |
{/* Add Server Button */} | |
<div className="mb-6"> | |
<button | |
onClick={() => setShowAddForm(!showAddForm)} | |
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center gap-2" | |
> | |
<Plus size={16} /> | |
Add MCP Server | |
</button> | |
</div> | |
{/* Add Server Form */} | |
{showAddForm && ( | |
<div className="bg-gray-700 rounded-lg p-4 mb-6"> | |
<h3 className="text-lg font-semibold text-white mb-4"> | |
Add New MCP Server | |
</h3> | |
<div className="space-y-4"> | |
<div> | |
<label className="block text-sm font-medium text-gray-300 mb-1"> | |
Server Name | |
</label> | |
<input | |
type="text" | |
value={newServer.name} | |
onChange={(e) => | |
setNewServer({ ...newServer, name: e.target.value }) | |
} | |
className="w-full bg-gray-600 text-white rounded px-3 py-2" | |
placeholder="My MCP Server" | |
/> | |
</div> | |
<div> | |
<label className="block text-sm font-medium text-gray-300 mb-1"> | |
Server URL | |
</label> | |
<input | |
type="url" | |
value={newServer.url} | |
onChange={(e) => | |
setNewServer({ ...newServer, url: e.target.value }) | |
} | |
className="w-full bg-gray-600 text-white rounded px-3 py-2" | |
placeholder="http://localhost:3000/mcp" | |
/> | |
</div> | |
<div> | |
<label className="block text-sm font-medium text-gray-300 mb-1"> | |
Transport | |
</label> | |
<select | |
value={newServer.transport} | |
onChange={(e) => | |
setNewServer({ | |
...newServer, | |
transport: e.target.value as MCPServerConfig["transport"], | |
}) | |
} | |
className="w-full bg-gray-600 text-white rounded px-3 py-2" | |
> | |
<option value="streamable-http">Streamable HTTP</option> | |
<option value="sse">Server-Sent Events</option> | |
<option value="websocket">WebSocket</option> | |
</select> | |
</div> | |
<div> | |
<label className="block text-sm font-medium text-gray-300 mb-1"> | |
Authentication | |
</label> | |
<select | |
value={newServer.auth?.type || "none"} | |
onChange={(e) => { | |
const authType = e.target.value; | |
if (authType === "none") { | |
setNewServer({ ...newServer, auth: undefined }); | |
} else { | |
setNewServer({ | |
...newServer, | |
auth: { | |
type: authType as "bearer" | "basic" | "oauth", | |
...(authType === "bearer" ? { token: "" } : {}), | |
...(authType === "basic" | |
? { username: "", password: "" } | |
: {}), | |
...(authType === "oauth" ? { token: "" } : {}), | |
}, | |
}); | |
} | |
}} | |
className="w-full bg-gray-600 text-white rounded px-3 py-2" | |
> | |
<option value="none">No Authentication</option> | |
<option value="bearer">Bearer Token</option> | |
<option value="basic">Basic Auth</option> | |
<option value="oauth">OAuth Token</option> | |
</select> | |
</div> | |
{/* Auth-specific fields */} | |
{newServer.auth?.type === "bearer" && ( | |
<div> | |
<label className="block text-sm font-medium text-gray-300 mb-1"> | |
Bearer Token | |
</label> | |
<input | |
type="password" | |
value={newServer.auth.token || ""} | |
onChange={(e) => | |
setNewServer({ | |
...newServer, | |
auth: { ...newServer.auth!, token: e.target.value }, | |
}) | |
} | |
className="w-full bg-gray-600 text-white rounded px-3 py-2" | |
placeholder="your-bearer-token" | |
/> | |
</div> | |
)} | |
{newServer.auth?.type === "basic" && ( | |
<> | |
<div> | |
<label className="block text-sm font-medium text-gray-300 mb-1"> | |
Username | |
</label> | |
<input | |
type="text" | |
value={newServer.auth.username || ""} | |
onChange={(e) => | |
setNewServer({ | |
...newServer, | |
auth: { | |
...newServer.auth!, | |
username: e.target.value, | |
}, | |
}) | |
} | |
className="w-full bg-gray-600 text-white rounded px-3 py-2" | |
placeholder="username" | |
/> | |
</div> | |
<div> | |
<label className="block text-sm font-medium text-gray-300 mb-1"> | |
Password | |
</label> | |
<input | |
type="password" | |
value={newServer.auth.password || ""} | |
onChange={(e) => | |
setNewServer({ | |
...newServer, | |
auth: { | |
...newServer.auth!, | |
password: e.target.value, | |
}, | |
}) | |
} | |
className="w-full bg-gray-600 text-white rounded px-3 py-2" | |
placeholder="password" | |
/> | |
</div> | |
</> | |
)} | |
{newServer.auth?.type === "oauth" && ( | |
<div> | |
<label className="block text-sm font-medium text-gray-300 mb-1"> | |
OAuth Authorization | |
</label> | |
<button | |
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded mb-2" | |
type="button" | |
onClick={async () => { | |
try { | |
// Persist name and transport for OAuthCallback | |
localStorage.setItem(STORAGE_KEYS.MCP_SERVER_NAME, newServer.name); | |
localStorage.setItem( | |
STORAGE_KEYS.MCP_SERVER_TRANSPORT, | |
newServer.transport | |
); | |
const endpoints = await discoverOAuthEndpoints( | |
newServer.url | |
); | |
if (!endpoints.clientId || !endpoints.redirectUri) { | |
throw new Error("Missing required OAuth configuration (clientId or redirectUri)"); | |
} | |
startOAuthFlow({ | |
authorizationEndpoint: | |
endpoints.authorizationEndpoint, | |
clientId: endpoints.clientId as string, | |
redirectUri: endpoints.redirectUri as string, | |
scopes: (endpoints.scopes || []) as string[], | |
}); | |
} catch (err) { | |
setNotification({ | |
message: "OAuth discovery failed: " + | |
(err instanceof Error ? err.message : String(err)), | |
type: 'error' | |
}); | |
setTimeout(() => setNotification(null), DEFAULTS.OAUTH_ERROR_TIMEOUT); | |
} | |
}} | |
> | |
Connect with OAuth | |
</button> | |
<p className="text-xs text-gray-400"> | |
You will be redirected to authorize this app with the MCP | |
server. | |
</p> | |
</div> | |
)} | |
<div className="flex items-center gap-2"> | |
<input | |
type="checkbox" | |
id="enabled" | |
checked={newServer.enabled} | |
onChange={(e) => | |
setNewServer({ ...newServer, enabled: e.target.checked }) | |
} | |
className="rounded" | |
/> | |
<label htmlFor="enabled" className="text-sm text-gray-300"> | |
Auto-connect when added | |
</label> | |
</div> | |
<div className="flex gap-2"> | |
<button | |
onClick={handleAddServer} | |
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded" | |
> | |
Add Server | |
</button> | |
<button | |
onClick={() => setShowAddForm(false)} | |
className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded" | |
> | |
Cancel | |
</button> | |
</div> | |
</div> | |
</div> | |
)} | |
{/* Server List */} | |
<div className="space-y-4"> | |
<h3 className="text-lg font-semibold text-white"> | |
Configured Servers | |
</h3> | |
{Object.values(mcpState.servers).length === 0 ? ( | |
<div className="text-gray-400 text-center py-8"> | |
No MCP servers configured. Add one to get started! | |
</div> | |
) : ( | |
Object.values(mcpState.servers).map((connection) => ( | |
<div | |
key={connection.config.id} | |
className="bg-gray-700 rounded-lg p-4" | |
> | |
<div className="flex items-center justify-between"> | |
<div className="flex items-center gap-3"> | |
<div | |
className={`w-3 h-3 rounded-full ${ | |
connection.isConnected ? "bg-green-400" : "bg-red-400" | |
}`} | |
/> | |
<div> | |
<h4 className="text-white font-medium"> | |
{connection.config.name} | |
</h4> | |
<p className="text-gray-400 text-sm"> | |
{connection.config.url} | |
</p> | |
<p className="text-gray-500 text-xs"> | |
Transport: {connection.config.transport} | |
{connection.config.auth && | |
` • Auth: ${connection.config.auth.type}`} | |
{connection.isConnected && | |
` • ${connection.tools.length} tools available`} | |
</p> | |
</div> | |
</div> | |
<div className="flex items-center gap-2"> | |
{/* Test Connection */} | |
<button | |
onClick={() => handleTestConnection(connection.config)} | |
disabled={testingConnection === connection.config.id} | |
className="p-2 text-yellow-400 hover:text-yellow-300 disabled:opacity-50" | |
title="Test Connection" | |
> | |
<TestTube size={16} /> | |
</button> | |
{/* Connect/Disconnect */} | |
<button | |
onClick={() => | |
handleToggleConnection( | |
connection.config.id, | |
connection.isConnected | |
) | |
} | |
className={`p-2 ${ | |
connection.isConnected | |
? "text-green-400 hover:text-green-300" | |
: "text-gray-400 hover:text-gray-300" | |
}`} | |
title={connection.isConnected ? "Disconnect" : "Connect"} | |
> | |
{connection.isConnected ? ( | |
<Wifi size={16} /> | |
) : ( | |
<WifiOff size={16} /> | |
)} | |
</button> | |
{/* Remove Server */} | |
<button | |
onClick={() => removeServer(connection.config.id)} | |
className="p-2 text-red-400 hover:text-red-300" | |
title="Remove Server" | |
> | |
<Trash2 size={16} /> | |
</button> | |
</div> | |
</div> | |
{connection.lastError && ( | |
<div className="mt-2 text-red-400 text-sm"> | |
Error: {connection.lastError} | |
</div> | |
)} | |
{connection.isConnected && connection.tools.length > 0 && ( | |
<div className="mt-3"> | |
<details className="text-sm"> | |
<summary className="text-gray-300 cursor-pointer"> | |
Available Tools ({connection.tools.length}) | |
</summary> | |
<div className="mt-2 space-y-1"> | |
{connection.tools.map((tool) => ( | |
<div key={tool.name} className="text-gray-400 pl-4"> | |
• {tool.name} -{" "} | |
{tool.description || "No description"} | |
</div> | |
))} | |
</div> | |
</details> | |
</div> | |
)} | |
</div> | |
)) | |
)} | |
</div> | |
{mcpState.error && ( | |
<div className="mt-4 p-4 bg-red-900 border border-red-700 rounded-lg text-red-200"> | |
<strong>Error:</strong> {mcpState.error} | |
</div> | |
)} | |
{notification && ( | |
<div className={`mt-4 p-4 border rounded-lg ${ | |
notification.type === 'success' | |
? 'bg-green-900 border-green-700 text-green-200' | |
: 'bg-red-900 border-red-700 text-red-200' | |
}`}> | |
{notification.message} | |
</div> | |
)} | |
</div> | |
</div> | |
); | |
}; | |