|
|
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()}`, |
|
|
}; |
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
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> |
|
|
</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> |
|
|
); |
|
|
}; |
|
|
|