LFM2-WebGPU / src /components /MCPServerManager.tsx
shreyask's picture
fix: validate OAuth configuration before starting the flow
4af45ea verified
raw
history blame
19 kB
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>
);
};