mirror of
https://github.com/siteboon/claudecodeui.git
synced 2025-12-13 13:49:43 +00:00
feat: Ability to add and control user level MCP Servers
This commit is contained in:
@@ -84,6 +84,84 @@ async function spawnClaude(command, options = {}, ws) {
|
|||||||
// Add basic flags
|
// Add basic flags
|
||||||
args.push('--output-format', 'stream-json', '--verbose');
|
args.push('--output-format', 'stream-json', '--verbose');
|
||||||
|
|
||||||
|
// Add MCP config flag only if MCP servers are configured
|
||||||
|
try {
|
||||||
|
console.log('🔍 Starting MCP config check...');
|
||||||
|
// Use already imported modules (fs.promises is imported as fs, path, os)
|
||||||
|
const fsSync = await import('fs'); // Import synchronous fs methods
|
||||||
|
console.log('✅ Successfully imported fs sync methods');
|
||||||
|
|
||||||
|
// Check for MCP config in ~/.claude.json
|
||||||
|
const claudeConfigPath = path.join(os.homedir(), '.claude.json');
|
||||||
|
|
||||||
|
console.log(`🔍 Checking for MCP configs in: ${claudeConfigPath}`);
|
||||||
|
console.log(` Claude config exists: ${fsSync.existsSync(claudeConfigPath)}`);
|
||||||
|
|
||||||
|
let hasMcpServers = false;
|
||||||
|
|
||||||
|
// Check Claude config for MCP servers
|
||||||
|
if (fsSync.existsSync(claudeConfigPath)) {
|
||||||
|
try {
|
||||||
|
const claudeConfig = JSON.parse(fsSync.readFileSync(claudeConfigPath, 'utf8'));
|
||||||
|
|
||||||
|
// Check global MCP servers
|
||||||
|
if (claudeConfig.mcpServers && Object.keys(claudeConfig.mcpServers).length > 0) {
|
||||||
|
console.log(`✅ Found ${Object.keys(claudeConfig.mcpServers).length} global MCP servers`);
|
||||||
|
hasMcpServers = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check project-specific MCP servers
|
||||||
|
if (!hasMcpServers && claudeConfig.claudeProjects) {
|
||||||
|
const currentProjectPath = process.cwd();
|
||||||
|
const projectConfig = claudeConfig.claudeProjects[currentProjectPath];
|
||||||
|
if (projectConfig && projectConfig.mcpServers && Object.keys(projectConfig.mcpServers).length > 0) {
|
||||||
|
console.log(`✅ Found ${Object.keys(projectConfig.mcpServers).length} project MCP servers`);
|
||||||
|
hasMcpServers = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`❌ Failed to parse Claude config:`, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🔍 hasMcpServers result: ${hasMcpServers}`);
|
||||||
|
|
||||||
|
if (hasMcpServers) {
|
||||||
|
// Use Claude config file if it has MCP servers
|
||||||
|
let configPath = null;
|
||||||
|
|
||||||
|
if (fsSync.existsSync(claudeConfigPath)) {
|
||||||
|
try {
|
||||||
|
const claudeConfig = JSON.parse(fsSync.readFileSync(claudeConfigPath, 'utf8'));
|
||||||
|
|
||||||
|
// Check if we have any MCP servers (global or project-specific)
|
||||||
|
const hasGlobalServers = claudeConfig.mcpServers && Object.keys(claudeConfig.mcpServers).length > 0;
|
||||||
|
const currentProjectPath = process.cwd();
|
||||||
|
const projectConfig = claudeConfig.claudeProjects && claudeConfig.claudeProjects[currentProjectPath];
|
||||||
|
const hasProjectServers = projectConfig && projectConfig.mcpServers && Object.keys(projectConfig.mcpServers).length > 0;
|
||||||
|
|
||||||
|
if (hasGlobalServers || hasProjectServers) {
|
||||||
|
configPath = claudeConfigPath;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// No valid config found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configPath) {
|
||||||
|
console.log(`📡 Adding MCP config: ${configPath}`);
|
||||||
|
args.push('--mcp-config', configPath);
|
||||||
|
} else {
|
||||||
|
console.log('⚠️ MCP servers detected but no valid config file found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// If there's any error checking for MCP configs, don't add the flag
|
||||||
|
console.log('❌ MCP config check failed:', error.message);
|
||||||
|
console.log('📍 Error stack:', error.stack);
|
||||||
|
console.log('Note: MCP config check failed, proceeding without MCP support');
|
||||||
|
}
|
||||||
|
|
||||||
// Add model for new sessions
|
// Add model for new sessions
|
||||||
if (!resume) {
|
if (!resume) {
|
||||||
args.push('--model', 'sonnet');
|
args.push('--model', 'sonnet');
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { getProjects, getSessions, getSessionMessages, renameProject, deleteSess
|
|||||||
import { spawnClaude, abortClaudeSession } from './claude-cli.js';
|
import { spawnClaude, abortClaudeSession } from './claude-cli.js';
|
||||||
import gitRoutes from './routes/git.js';
|
import gitRoutes from './routes/git.js';
|
||||||
import authRoutes from './routes/auth.js';
|
import authRoutes from './routes/auth.js';
|
||||||
|
import mcpRoutes from './routes/mcp.js';
|
||||||
import { initializeDatabase } from './database/db.js';
|
import { initializeDatabase } from './database/db.js';
|
||||||
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
|
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
|
||||||
|
|
||||||
@@ -131,17 +132,6 @@ async function setupProjectsWatcher() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get the first non-localhost IP address
|
// Get the first non-localhost IP address
|
||||||
function getServerIP() {
|
|
||||||
const interfaces = os.networkInterfaces();
|
|
||||||
for (const name of Object.keys(interfaces)) {
|
|
||||||
for (const iface of interfaces[name]) {
|
|
||||||
if (iface.family === 'IPv4' && !iface.internal) {
|
|
||||||
return iface.address;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 'localhost';
|
|
||||||
}
|
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const server = http.createServer(app);
|
const server = http.createServer(app);
|
||||||
@@ -183,14 +173,16 @@ app.use('/api/auth', authRoutes);
|
|||||||
// Git API Routes (protected)
|
// Git API Routes (protected)
|
||||||
app.use('/api/git', authenticateToken, gitRoutes);
|
app.use('/api/git', authenticateToken, gitRoutes);
|
||||||
|
|
||||||
|
// MCP API Routes (protected)
|
||||||
|
app.use('/api/mcp', authenticateToken, mcpRoutes);
|
||||||
|
|
||||||
// Static files served after API routes
|
// Static files served after API routes
|
||||||
app.use(express.static(path.join(__dirname, '../dist')));
|
app.use(express.static(path.join(__dirname, '../dist')));
|
||||||
|
|
||||||
// API Routes (protected)
|
// API Routes (protected)
|
||||||
app.get('/api/config', authenticateToken, (req, res) => {
|
app.get('/api/config', authenticateToken, (req, res) => {
|
||||||
// Always use the server's actual IP and port for WebSocket connections
|
// Always use the server's actual IP and port for WebSocket connections
|
||||||
const serverIP = getServerIP();
|
const host = req.headers.host || `${req.hostname}:${PORT}`;
|
||||||
const host = `${serverIP}:${PORT}`;
|
|
||||||
const protocol = req.protocol === 'https' || req.get('x-forwarded-proto') === 'https' ? 'wss' : 'ws';
|
const protocol = req.protocol === 'https' || req.get('x-forwarded-proto') === 'https' ? 'wss' : 'ws';
|
||||||
|
|
||||||
console.log('Config API called - Returning host:', host, 'Protocol:', protocol);
|
console.log('Config API called - Returning host:', host, 'Protocol:', protocol);
|
||||||
@@ -428,8 +420,6 @@ app.get('/api/projects/:projectName/files', authenticateToken, async (req, res)
|
|||||||
|
|
||||||
const files = await getFileTree(actualPath, 3, 0, true);
|
const files = await getFileTree(actualPath, 3, 0, true);
|
||||||
const hiddenFiles = files.filter(f => f.name.startsWith('.'));
|
const hiddenFiles = files.filter(f => f.name.startsWith('.'));
|
||||||
console.log('📄 Found', files.length, 'files/folders, including', hiddenFiles.length, 'hidden files');
|
|
||||||
console.log('🔍 Hidden files:', hiddenFiles.map(f => f.name));
|
|
||||||
res.json(files);
|
res.json(files);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ File tree error:', error.message);
|
console.error('❌ File tree error:', error.message);
|
||||||
|
|||||||
286
server/routes/mcp.js
Normal file
286
server/routes/mcp.js
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { promises as fs } from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import os from 'os';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { dirname } from 'path';
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
// Claude CLI command routes
|
||||||
|
|
||||||
|
// GET /api/mcp/cli/list - List MCP servers using Claude CLI
|
||||||
|
router.get('/cli/list', async (req, res) => {
|
||||||
|
try {
|
||||||
|
console.log('📋 Listing MCP servers using Claude CLI');
|
||||||
|
|
||||||
|
const { spawn } = await import('child_process');
|
||||||
|
const { promisify } = await import('util');
|
||||||
|
const exec = promisify(spawn);
|
||||||
|
|
||||||
|
const process = spawn('claude', ['mcp', 'list', '-s', 'user'], {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe']
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
|
||||||
|
process.stdout.on('data', (data) => {
|
||||||
|
stdout += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
process.stderr.on('data', (data) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('close', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
res.json({ success: true, output: stdout, servers: parseClaudeListOutput(stdout) });
|
||||||
|
} else {
|
||||||
|
console.error('Claude CLI error:', stderr);
|
||||||
|
res.status(500).json({ error: 'Claude CLI command failed', details: stderr });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('error', (error) => {
|
||||||
|
console.error('Error running Claude CLI:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to run Claude CLI', details: error.message });
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error listing MCP servers via CLI:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to list MCP servers', details: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/mcp/cli/add - Add MCP server using Claude CLI
|
||||||
|
router.post('/cli/add', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name, type = 'stdio', command, args = [], url, headers = {}, env = {} } = req.body;
|
||||||
|
|
||||||
|
console.log('➕ Adding MCP server using Claude CLI:', name);
|
||||||
|
|
||||||
|
const { spawn } = await import('child_process');
|
||||||
|
|
||||||
|
let cliArgs = ['mcp', 'add'];
|
||||||
|
|
||||||
|
if (type === 'http') {
|
||||||
|
cliArgs.push('--transport', 'http', name, '-s', 'user', url);
|
||||||
|
// Add headers if provided
|
||||||
|
Object.entries(headers).forEach(([key, value]) => {
|
||||||
|
cliArgs.push('--header', `${key}: ${value}`);
|
||||||
|
});
|
||||||
|
} else if (type === 'sse') {
|
||||||
|
cliArgs.push('--transport', 'sse', name, '-s', 'user', url);
|
||||||
|
// Add headers if provided
|
||||||
|
Object.entries(headers).forEach(([key, value]) => {
|
||||||
|
cliArgs.push('--header', `${key}: ${value}`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// stdio (default): claude mcp add <name> -s user <command> [args...]
|
||||||
|
cliArgs.push(name, '-s', 'user');
|
||||||
|
// Add environment variables
|
||||||
|
Object.entries(env).forEach(([key, value]) => {
|
||||||
|
cliArgs.push('-e', `${key}=${value}`);
|
||||||
|
});
|
||||||
|
cliArgs.push(command);
|
||||||
|
if (args && args.length > 0) {
|
||||||
|
cliArgs.push(...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🔧 Running Claude CLI command:', 'claude', cliArgs.join(' '));
|
||||||
|
|
||||||
|
const process = spawn('claude', cliArgs, {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe']
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
|
||||||
|
process.stdout.on('data', (data) => {
|
||||||
|
stdout += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
process.stderr.on('data', (data) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('close', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
res.json({ success: true, output: stdout, message: `MCP server "${name}" added successfully` });
|
||||||
|
} else {
|
||||||
|
console.error('Claude CLI error:', stderr);
|
||||||
|
res.status(400).json({ error: 'Claude CLI command failed', details: stderr });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('error', (error) => {
|
||||||
|
console.error('Error running Claude CLI:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to run Claude CLI', details: error.message });
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding MCP server via CLI:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to add MCP server', details: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/mcp/cli/remove/:name - Remove MCP server using Claude CLI
|
||||||
|
router.delete('/cli/remove/:name', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name } = req.params;
|
||||||
|
|
||||||
|
console.log('🗑️ Removing MCP server using Claude CLI:', name);
|
||||||
|
|
||||||
|
const { spawn } = await import('child_process');
|
||||||
|
|
||||||
|
const process = spawn('claude', ['mcp', 'remove', '-s', 'user', name], {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe']
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
|
||||||
|
process.stdout.on('data', (data) => {
|
||||||
|
stdout += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
process.stderr.on('data', (data) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('close', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
res.json({ success: true, output: stdout, message: `MCP server "${name}" removed successfully` });
|
||||||
|
} else {
|
||||||
|
console.error('Claude CLI error:', stderr);
|
||||||
|
res.status(400).json({ error: 'Claude CLI command failed', details: stderr });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('error', (error) => {
|
||||||
|
console.error('Error running Claude CLI:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to run Claude CLI', details: error.message });
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error removing MCP server via CLI:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to remove MCP server', details: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/mcp/cli/get/:name - Get MCP server details using Claude CLI
|
||||||
|
router.get('/cli/get/:name', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name } = req.params;
|
||||||
|
|
||||||
|
console.log('📄 Getting MCP server details using Claude CLI:', name);
|
||||||
|
|
||||||
|
const { spawn } = await import('child_process');
|
||||||
|
|
||||||
|
const process = spawn('claude', ['mcp', 'get', '-s', 'user', name], {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe']
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
|
||||||
|
process.stdout.on('data', (data) => {
|
||||||
|
stdout += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
process.stderr.on('data', (data) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('close', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
res.json({ success: true, output: stdout, server: parseClaudeGetOutput(stdout) });
|
||||||
|
} else {
|
||||||
|
console.error('Claude CLI error:', stderr);
|
||||||
|
res.status(404).json({ error: 'Claude CLI command failed', details: stderr });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('error', (error) => {
|
||||||
|
console.error('Error running Claude CLI:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to run Claude CLI', details: error.message });
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting MCP server details via CLI:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to get MCP server details', details: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper functions to parse Claude CLI output
|
||||||
|
function parseClaudeListOutput(output) {
|
||||||
|
// Parse the output from 'claude mcp list' command
|
||||||
|
// Format: "name: command/url" or "name: url (TYPE)"
|
||||||
|
const servers = [];
|
||||||
|
const lines = output.split('\n').filter(line => line.trim());
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.includes(':')) {
|
||||||
|
const colonIndex = line.indexOf(':');
|
||||||
|
const name = line.substring(0, colonIndex).trim();
|
||||||
|
const rest = line.substring(colonIndex + 1).trim();
|
||||||
|
|
||||||
|
let type = 'stdio'; // default type
|
||||||
|
|
||||||
|
// Check if it has transport type in parentheses like "(SSE)" or "(HTTP)"
|
||||||
|
const typeMatch = rest.match(/\((\w+)\)\s*$/);
|
||||||
|
if (typeMatch) {
|
||||||
|
type = typeMatch[1].toLowerCase();
|
||||||
|
} else if (rest.startsWith('http://') || rest.startsWith('https://')) {
|
||||||
|
// If it's a URL but no explicit type, assume HTTP
|
||||||
|
type = 'http';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
servers.push({
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
status: 'active'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🔍 Parsed Claude CLI servers:', servers);
|
||||||
|
return servers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseClaudeGetOutput(output) {
|
||||||
|
// Parse the output from 'claude mcp get <name>' command
|
||||||
|
// This is a simple parser - might need adjustment based on actual output format
|
||||||
|
try {
|
||||||
|
// Try to extract JSON if present
|
||||||
|
const jsonMatch = output.match(/\{[\s\S]*\}/);
|
||||||
|
if (jsonMatch) {
|
||||||
|
return JSON.parse(jsonMatch[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, parse as text
|
||||||
|
const server = { raw_output: output };
|
||||||
|
const lines = output.split('\n');
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.includes('Name:')) {
|
||||||
|
server.name = line.split(':')[1]?.trim();
|
||||||
|
} else if (line.includes('Type:')) {
|
||||||
|
server.type = line.split(':')[1]?.trim();
|
||||||
|
} else if (line.includes('Command:')) {
|
||||||
|
server.command = line.split(':')[1]?.trim();
|
||||||
|
} else if (line.includes('URL:')) {
|
||||||
|
server.url = line.split(':')[1]?.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return server;
|
||||||
|
} catch (error) {
|
||||||
|
return { raw_output: output, parse_error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -3,7 +3,7 @@ import { Button } from './ui/button';
|
|||||||
import { Input } from './ui/input';
|
import { Input } from './ui/input';
|
||||||
import { ScrollArea } from './ui/scroll-area';
|
import { ScrollArea } from './ui/scroll-area';
|
||||||
import { Badge } from './ui/badge';
|
import { Badge } from './ui/badge';
|
||||||
import { X, Plus, Settings, Shield, AlertTriangle, Moon, Sun } from 'lucide-react';
|
import { X, Plus, Settings, Shield, AlertTriangle, Moon, Sun, Server, Edit3, Trash2, Play, Globe, Terminal, Zap } from 'lucide-react';
|
||||||
import { useTheme } from '../contexts/ThemeContext';
|
import { useTheme } from '../contexts/ThemeContext';
|
||||||
|
|
||||||
function ToolsSettings({ isOpen, onClose }) {
|
function ToolsSettings({ isOpen, onClose }) {
|
||||||
@@ -17,6 +17,32 @@ function ToolsSettings({ isOpen, onClose }) {
|
|||||||
const [saveStatus, setSaveStatus] = useState(null);
|
const [saveStatus, setSaveStatus] = useState(null);
|
||||||
const [projectSortOrder, setProjectSortOrder] = useState('name');
|
const [projectSortOrder, setProjectSortOrder] = useState('name');
|
||||||
|
|
||||||
|
// MCP server management state
|
||||||
|
const [mcpServers, setMcpServers] = useState([]);
|
||||||
|
const [showMcpForm, setShowMcpForm] = useState(false);
|
||||||
|
const [editingMcpServer, setEditingMcpServer] = useState(null);
|
||||||
|
const [mcpFormData, setMcpFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
type: 'stdio',
|
||||||
|
scope: 'user', // Always use user scope
|
||||||
|
config: {
|
||||||
|
command: '',
|
||||||
|
args: [],
|
||||||
|
env: {},
|
||||||
|
url: '',
|
||||||
|
headers: {},
|
||||||
|
timeout: 30000
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const [mcpLoading, setMcpLoading] = useState(false);
|
||||||
|
const [mcpTestResults, setMcpTestResults] = useState({});
|
||||||
|
const [mcpConfigTestResult, setMcpConfigTestResult] = useState(null);
|
||||||
|
const [mcpConfigTesting, setMcpConfigTesting] = useState(false);
|
||||||
|
const [mcpConfigTested, setMcpConfigTested] = useState(false);
|
||||||
|
const [mcpServerTools, setMcpServerTools] = useState({});
|
||||||
|
const [mcpToolsLoading, setMcpToolsLoading] = useState({});
|
||||||
|
const [activeTab, setActiveTab] = useState('tools');
|
||||||
|
|
||||||
// Common tool patterns
|
// Common tool patterns
|
||||||
const commonTools = [
|
const commonTools = [
|
||||||
'Bash(git log:*)',
|
'Bash(git log:*)',
|
||||||
@@ -35,13 +61,219 @@ function ToolsSettings({ isOpen, onClose }) {
|
|||||||
'WebSearch'
|
'WebSearch'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// MCP API functions
|
||||||
|
const fetchMcpServers = async () => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('auth-token');
|
||||||
|
|
||||||
|
// First try to get servers using Claude CLI
|
||||||
|
const cliResponse = await fetch('/api/mcp/cli/list', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cliResponse.ok) {
|
||||||
|
const cliData = await cliResponse.json();
|
||||||
|
if (cliData.success && cliData.servers) {
|
||||||
|
// Convert CLI format to our format
|
||||||
|
const servers = cliData.servers.map(server => ({
|
||||||
|
id: server.name,
|
||||||
|
name: server.name,
|
||||||
|
type: server.type,
|
||||||
|
scope: 'user',
|
||||||
|
config: {
|
||||||
|
command: server.command || '',
|
||||||
|
args: server.args || [],
|
||||||
|
env: server.env || {},
|
||||||
|
url: server.url || '',
|
||||||
|
headers: server.headers || {},
|
||||||
|
timeout: 30000
|
||||||
|
},
|
||||||
|
created: new Date().toISOString(),
|
||||||
|
updated: new Date().toISOString()
|
||||||
|
}));
|
||||||
|
setMcpServers(servers);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to direct config reading
|
||||||
|
const response = await fetch('/api/mcp/servers?scope=user', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setMcpServers(data.servers || []);
|
||||||
|
} else {
|
||||||
|
console.error('Failed to fetch MCP servers');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching MCP servers:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveMcpServer = async (serverData) => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('auth-token');
|
||||||
|
|
||||||
|
if (editingMcpServer) {
|
||||||
|
// For editing, remove old server and add new one
|
||||||
|
await deleteMcpServer(editingMcpServer.id, 'user');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use Claude CLI to add the server
|
||||||
|
const response = await fetch('/api/mcp/cli/add', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: serverData.name,
|
||||||
|
type: serverData.type,
|
||||||
|
command: serverData.config?.command,
|
||||||
|
args: serverData.config?.args || [],
|
||||||
|
url: serverData.config?.url,
|
||||||
|
headers: serverData.config?.headers || {},
|
||||||
|
env: serverData.config?.env || {}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.success) {
|
||||||
|
await fetchMcpServers(); // Refresh the list
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error || 'Failed to save server via Claude CLI');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || 'Failed to save server');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving MCP server:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteMcpServer = async (serverId, scope = 'user') => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('auth-token');
|
||||||
|
|
||||||
|
// Use Claude CLI to remove the server
|
||||||
|
const response = await fetch(`/api/mcp/cli/remove/${serverId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.success) {
|
||||||
|
await fetchMcpServers(); // Refresh the list
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error || 'Failed to delete server via Claude CLI');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || 'Failed to delete server');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting MCP server:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const testMcpServer = async (serverId, scope = 'user') => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('auth-token');
|
||||||
|
const response = await fetch(`/api/mcp/servers/${serverId}/test?scope=${scope}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
return data.testResult;
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || 'Failed to test server');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error testing MCP server:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const testMcpConfiguration = async (formData) => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('auth-token');
|
||||||
|
const response = await fetch('/api/mcp/servers/test', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(formData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
return data.testResult;
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || 'Failed to test configuration');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error testing MCP configuration:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const discoverMcpTools = async (serverId, scope = 'user') => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('auth-token');
|
||||||
|
const response = await fetch(`/api/mcp/servers/${serverId}/tools?scope=${scope}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
return data.toolsResult;
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || 'Failed to discover tools');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error discovering MCP tools:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
loadSettings();
|
loadSettings();
|
||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
const loadSettings = () => {
|
const loadSettings = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
// Load from localStorage
|
// Load from localStorage
|
||||||
@@ -60,6 +292,9 @@ function ToolsSettings({ isOpen, onClose }) {
|
|||||||
setSkipPermissions(false);
|
setSkipPermissions(false);
|
||||||
setProjectSortOrder('name');
|
setProjectSortOrder('name');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load MCP servers from API
|
||||||
|
await fetchMcpServers();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading tool settings:', error);
|
console.error('Error loading tool settings:', error);
|
||||||
// Set defaults on error
|
// Set defaults on error
|
||||||
@@ -122,6 +357,149 @@ function ToolsSettings({ isOpen, onClose }) {
|
|||||||
setDisallowedTools(disallowedTools.filter(t => t !== tool));
|
setDisallowedTools(disallowedTools.filter(t => t !== tool));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// MCP form handling functions
|
||||||
|
const resetMcpForm = () => {
|
||||||
|
setMcpFormData({
|
||||||
|
name: '',
|
||||||
|
type: 'stdio',
|
||||||
|
scope: 'user', // Always use user scope
|
||||||
|
config: {
|
||||||
|
command: '',
|
||||||
|
args: [],
|
||||||
|
env: {},
|
||||||
|
url: '',
|
||||||
|
headers: {},
|
||||||
|
timeout: 30000
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setEditingMcpServer(null);
|
||||||
|
setShowMcpForm(false);
|
||||||
|
setMcpConfigTestResult(null);
|
||||||
|
setMcpConfigTested(false);
|
||||||
|
setMcpConfigTesting(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openMcpForm = (server = null) => {
|
||||||
|
if (server) {
|
||||||
|
setEditingMcpServer(server);
|
||||||
|
setMcpFormData({
|
||||||
|
name: server.name,
|
||||||
|
type: server.type,
|
||||||
|
scope: server.scope,
|
||||||
|
config: { ...server.config }
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
resetMcpForm();
|
||||||
|
}
|
||||||
|
setShowMcpForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMcpSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
setMcpLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await saveMcpServer(mcpFormData);
|
||||||
|
resetMcpForm();
|
||||||
|
setSaveStatus('success');
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Error: ${error.message}`);
|
||||||
|
setSaveStatus('error');
|
||||||
|
} finally {
|
||||||
|
setMcpLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMcpDelete = async (serverId, scope) => {
|
||||||
|
if (confirm('Are you sure you want to delete this MCP server?')) {
|
||||||
|
try {
|
||||||
|
await deleteMcpServer(serverId, scope);
|
||||||
|
setSaveStatus('success');
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Error: ${error.message}`);
|
||||||
|
setSaveStatus('error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMcpTest = async (serverId, scope) => {
|
||||||
|
try {
|
||||||
|
setMcpTestResults({ ...mcpTestResults, [serverId]: { loading: true } });
|
||||||
|
const result = await testMcpServer(serverId, scope);
|
||||||
|
setMcpTestResults({ ...mcpTestResults, [serverId]: result });
|
||||||
|
} catch (error) {
|
||||||
|
setMcpTestResults({
|
||||||
|
...mcpTestResults,
|
||||||
|
[serverId]: {
|
||||||
|
success: false,
|
||||||
|
message: error.message,
|
||||||
|
details: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMcpToolsDiscovery = async (serverId, scope) => {
|
||||||
|
try {
|
||||||
|
setMcpToolsLoading({ ...mcpToolsLoading, [serverId]: true });
|
||||||
|
const result = await discoverMcpTools(serverId, scope);
|
||||||
|
setMcpServerTools({ ...mcpServerTools, [serverId]: result });
|
||||||
|
} catch (error) {
|
||||||
|
setMcpServerTools({
|
||||||
|
...mcpServerTools,
|
||||||
|
[serverId]: {
|
||||||
|
success: false,
|
||||||
|
tools: [],
|
||||||
|
resources: [],
|
||||||
|
prompts: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setMcpToolsLoading({ ...mcpToolsLoading, [serverId]: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateMcpConfig = (key, value) => {
|
||||||
|
setMcpFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
config: {
|
||||||
|
...prev.config,
|
||||||
|
[key]: value
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
// Reset test status when configuration changes
|
||||||
|
setMcpConfigTestResult(null);
|
||||||
|
setMcpConfigTested(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTestConfiguration = async () => {
|
||||||
|
setMcpConfigTesting(true);
|
||||||
|
try {
|
||||||
|
const result = await testMcpConfiguration(mcpFormData);
|
||||||
|
setMcpConfigTestResult(result);
|
||||||
|
setMcpConfigTested(true);
|
||||||
|
} catch (error) {
|
||||||
|
setMcpConfigTestResult({
|
||||||
|
success: false,
|
||||||
|
message: error.message,
|
||||||
|
details: []
|
||||||
|
});
|
||||||
|
setMcpConfigTested(true);
|
||||||
|
} finally {
|
||||||
|
setMcpConfigTesting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTransportIcon = (type) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'stdio': return <Terminal className="w-4 h-4" />;
|
||||||
|
case 'sse': return <Zap className="w-4 h-4" />;
|
||||||
|
case 'http': return <Globe className="w-4 h-4" />;
|
||||||
|
default: return <Server className="w-4 h-4" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -131,7 +509,7 @@ function ToolsSettings({ isOpen, onClose }) {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Settings className="w-5 h-5 md:w-6 md:h-6 text-blue-600" />
|
<Settings className="w-5 h-5 md:w-6 md:h-6 text-blue-600" />
|
||||||
<h2 className="text-lg md:text-xl font-semibold text-foreground">
|
<h2 className="text-lg md:text-xl font-semibold text-foreground">
|
||||||
Tools Settings
|
Settings
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -145,16 +523,41 @@ function ToolsSettings({ isOpen, onClose }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{/* Tab Navigation */}
|
||||||
|
<div className="border-b border-border">
|
||||||
|
<div className="flex px-4 md:px-6">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('tools')}
|
||||||
|
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||||
|
activeTab === 'tools'
|
||||||
|
? 'border-blue-600 text-blue-600 dark:text-blue-400'
|
||||||
|
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Tools
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('appearance')}
|
||||||
|
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||||
|
activeTab === 'appearance'
|
||||||
|
? 'border-blue-600 text-blue-600 dark:text-blue-400'
|
||||||
|
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Appearance
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="p-4 md:p-6 space-y-6 md:space-y-8 pb-safe-area-inset-bottom">
|
<div className="p-4 md:p-6 space-y-6 md:space-y-8 pb-safe-area-inset-bottom">
|
||||||
|
|
||||||
|
{/* Appearance Tab */}
|
||||||
|
{activeTab === 'appearance' && (
|
||||||
|
<div className="space-y-6 md:space-y-8">
|
||||||
|
{activeTab === 'appearance' && (
|
||||||
|
<div className="space-y-6 md:space-y-8">
|
||||||
{/* Theme Settings */}
|
{/* Theme Settings */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{isDarkMode ? <Moon className="w-5 h-5 text-blue-500" /> : <Sun className="w-5 h-5 text-yellow-500" />}
|
|
||||||
<h3 className="text-lg font-medium text-foreground">
|
|
||||||
Appearance
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
<div className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -186,9 +589,12 @@ function ToolsSettings({ isOpen, onClose }) {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Project Sorting - Moved under Appearance */}
|
{/* Project Sorting */}
|
||||||
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
<div className="space-y-4">
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-foreground">
|
<div className="font-medium text-foreground">
|
||||||
@@ -210,6 +616,14 @@ function ToolsSettings({ isOpen, onClose }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tools Tab */}
|
||||||
|
{activeTab === 'tools' && (
|
||||||
|
<div className="space-y-6 md:space-y-8">
|
||||||
|
|
||||||
{/* Skip Permissions */}
|
{/* Skip Permissions */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -393,6 +807,433 @@ function ToolsSettings({ isOpen, onClose }) {
|
|||||||
<li><code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">"Bash(rm:*)"</code> - Block all rm commands (dangerous)</li>
|
<li><code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">"Bash(rm:*)"</code> - Block all rm commands (dangerous)</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* MCP Server Management */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Server className="w-5 h-5 text-purple-500" />
|
||||||
|
<h3 className="text-lg font-medium text-foreground">
|
||||||
|
MCP Servers
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Model Context Protocol servers provide additional tools and data sources to Claude
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<Button
|
||||||
|
onClick={() => openMcpForm()}
|
||||||
|
className="bg-purple-600 hover:bg-purple-700 text-white"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Add MCP Server
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* MCP Servers List */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{mcpServers.map(server => (
|
||||||
|
<div key={server.id} className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
{getTransportIcon(server.type)}
|
||||||
|
<span className="font-medium text-foreground">{server.name}</span>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{server.type}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{server.scope}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-muted-foreground space-y-1">
|
||||||
|
{server.type === 'stdio' && server.config.command && (
|
||||||
|
<div>Command: <code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{server.config.command}</code></div>
|
||||||
|
)}
|
||||||
|
{(server.type === 'sse' || server.type === 'http') && server.config.url && (
|
||||||
|
<div>URL: <code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{server.config.url}</code></div>
|
||||||
|
)}
|
||||||
|
{server.config.args && server.config.args.length > 0 && (
|
||||||
|
<div>Args: <code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{server.config.args.join(' ')}</code></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Test Results */}
|
||||||
|
{mcpTestResults[server.id] && (
|
||||||
|
<div className={`mt-2 p-2 rounded text-xs ${
|
||||||
|
mcpTestResults[server.id].success
|
||||||
|
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
|
||||||
|
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
|
||||||
|
}`}>
|
||||||
|
<div className="font-medium">{mcpTestResults[server.id].message}</div>
|
||||||
|
{mcpTestResults[server.id].details && mcpTestResults[server.id].details.length > 0 && (
|
||||||
|
<ul className="mt-1 space-y-0.5">
|
||||||
|
{mcpTestResults[server.id].details.map((detail, i) => (
|
||||||
|
<li key={i}>• {detail}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tools Discovery Results */}
|
||||||
|
{mcpServerTools[server.id] && (
|
||||||
|
<div className="mt-2 p-2 rounded text-xs bg-blue-50 dark:bg-blue-900/20 text-blue-800 dark:text-blue-200 border border-blue-200 dark:border-blue-800">
|
||||||
|
<div className="font-medium mb-2">Available Tools & Resources</div>
|
||||||
|
|
||||||
|
{mcpServerTools[server.id].tools && mcpServerTools[server.id].tools.length > 0 && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<div className="font-medium text-xs mb-1">Tools ({mcpServerTools[server.id].tools.length}):</div>
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{mcpServerTools[server.id].tools.map((tool, i) => (
|
||||||
|
<li key={i} className="flex items-start gap-1">
|
||||||
|
<span className="text-blue-400 mt-0.5">•</span>
|
||||||
|
<div>
|
||||||
|
<code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">{tool.name}</code>
|
||||||
|
{tool.description && tool.description !== 'No description provided' && (
|
||||||
|
<span className="ml-1 text-xs opacity-75">- {tool.description}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mcpServerTools[server.id].resources && mcpServerTools[server.id].resources.length > 0 && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<div className="font-medium text-xs mb-1">Resources ({mcpServerTools[server.id].resources.length}):</div>
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{mcpServerTools[server.id].resources.map((resource, i) => (
|
||||||
|
<li key={i} className="flex items-start gap-1">
|
||||||
|
<span className="text-blue-400 mt-0.5">•</span>
|
||||||
|
<div>
|
||||||
|
<code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">{resource.name}</code>
|
||||||
|
{resource.description && resource.description !== 'No description provided' && (
|
||||||
|
<span className="ml-1 text-xs opacity-75">- {resource.description}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mcpServerTools[server.id].prompts && mcpServerTools[server.id].prompts.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-xs mb-1">Prompts ({mcpServerTools[server.id].prompts.length}):</div>
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{mcpServerTools[server.id].prompts.map((prompt, i) => (
|
||||||
|
<li key={i} className="flex items-start gap-1">
|
||||||
|
<span className="text-blue-400 mt-0.5">•</span>
|
||||||
|
<div>
|
||||||
|
<code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">{prompt.name}</code>
|
||||||
|
{prompt.description && prompt.description !== 'No description provided' && (
|
||||||
|
<span className="ml-1 text-xs opacity-75">- {prompt.description}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(!mcpServerTools[server.id].tools || mcpServerTools[server.id].tools.length === 0) &&
|
||||||
|
(!mcpServerTools[server.id].resources || mcpServerTools[server.id].resources.length === 0) &&
|
||||||
|
(!mcpServerTools[server.id].prompts || mcpServerTools[server.id].prompts.length === 0) && (
|
||||||
|
<div className="text-xs opacity-75">No tools, resources, or prompts discovered</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 ml-4">
|
||||||
|
<Button
|
||||||
|
onClick={() => handleMcpTest(server.id, server.scope)}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={mcpTestResults[server.id]?.loading}
|
||||||
|
className="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||||
|
title="Test connection"
|
||||||
|
>
|
||||||
|
{mcpTestResults[server.id]?.loading ? (
|
||||||
|
<div className="w-4 h-4 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" />
|
||||||
|
) : (
|
||||||
|
<Play className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleMcpToolsDiscovery(server.id, server.scope)}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={mcpToolsLoading[server.id]}
|
||||||
|
className="text-purple-600 hover:text-purple-700 dark:text-purple-400 dark:hover:text-purple-300"
|
||||||
|
title="Discover tools"
|
||||||
|
>
|
||||||
|
{mcpToolsLoading[server.id] ? (
|
||||||
|
<div className="w-4 h-4 animate-spin rounded-full border-2 border-purple-600 border-t-transparent" />
|
||||||
|
) : (
|
||||||
|
<Settings className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => openMcpForm(server)}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-gray-600 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
|
||||||
|
>
|
||||||
|
<Edit3 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleMcpDelete(server.id, server.scope)}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{mcpServers.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
|
No MCP servers configured
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* MCP Server Form Modal */}
|
||||||
|
{showMcpForm && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[110] p-4">
|
||||||
|
<div className="bg-background border border-border rounded-lg w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||||
|
<h3 className="text-lg font-medium text-foreground">
|
||||||
|
{editingMcpServer ? 'Edit MCP Server' : 'Add MCP Server'}
|
||||||
|
</h3>
|
||||||
|
<Button variant="ghost" size="sm" onClick={resetMcpForm}>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleMcpSubmit} className="p-4 space-y-4">
|
||||||
|
{/* Basic Info */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
Server Name *
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={mcpFormData.name}
|
||||||
|
onChange={(e) => {
|
||||||
|
setMcpFormData(prev => ({...prev, name: e.target.value}));
|
||||||
|
setMcpConfigTestResult(null);
|
||||||
|
setMcpConfigTested(false);
|
||||||
|
}}
|
||||||
|
placeholder="my-server"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
Transport Type *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={mcpFormData.type}
|
||||||
|
onChange={(e) => {
|
||||||
|
setMcpFormData(prev => ({...prev, type: e.target.value}));
|
||||||
|
setMcpConfigTestResult(null);
|
||||||
|
setMcpConfigTested(false);
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="stdio">stdio</option>
|
||||||
|
<option value="sse">SSE</option>
|
||||||
|
<option value="http">HTTP</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scope is fixed to user - no selection needed */}
|
||||||
|
|
||||||
|
{/* Transport-specific Config */}
|
||||||
|
{mcpFormData.type === 'stdio' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
Command *
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={mcpFormData.config.command}
|
||||||
|
onChange={(e) => updateMcpConfig('command', e.target.value)}
|
||||||
|
placeholder="/path/to/mcp-server"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
Arguments (one per line)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={Array.isArray(mcpFormData.config.args) ? mcpFormData.config.args.join('\n') : ''}
|
||||||
|
onChange={(e) => updateMcpConfig('args', e.target.value.split('\n').filter(arg => arg.trim()))}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
rows="3"
|
||||||
|
placeholder="--api-key abc123"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(mcpFormData.type === 'sse' || mcpFormData.type === 'http') && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
URL *
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={mcpFormData.config.url}
|
||||||
|
onChange={(e) => updateMcpConfig('url', e.target.value)}
|
||||||
|
placeholder="https://api.example.com/mcp"
|
||||||
|
type="url"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Environment Variables */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
Environment Variables (KEY=value, one per line)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={Object.entries(mcpFormData.config.env || {}).map(([k, v]) => `${k}=${v}`).join('\n')}
|
||||||
|
onChange={(e) => {
|
||||||
|
const env = {};
|
||||||
|
e.target.value.split('\n').forEach(line => {
|
||||||
|
const [key, ...valueParts] = line.split('=');
|
||||||
|
if (key && key.trim()) {
|
||||||
|
env[key.trim()] = valueParts.join('=').trim();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
updateMcpConfig('env', env);
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
rows="3"
|
||||||
|
placeholder="API_KEY=your-key DEBUG=true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(mcpFormData.type === 'sse' || mcpFormData.type === 'http') && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
Headers (KEY=value, one per line)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={Object.entries(mcpFormData.config.headers || {}).map(([k, v]) => `${k}=${v}`).join('\n')}
|
||||||
|
onChange={(e) => {
|
||||||
|
const headers = {};
|
||||||
|
e.target.value.split('\n').forEach(line => {
|
||||||
|
const [key, ...valueParts] = line.split('=');
|
||||||
|
if (key && key.trim()) {
|
||||||
|
headers[key.trim()] = valueParts.join('=').trim();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
updateMcpConfig('headers', headers);
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Authorization=Bearer token X-API-Key=your-key"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Test Configuration Section */}
|
||||||
|
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h4 className="font-medium text-foreground">Configuration Test</h4>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleTestConfiguration}
|
||||||
|
disabled={mcpConfigTesting || !mcpFormData.name.trim()}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-blue-600 border-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20"
|
||||||
|
>
|
||||||
|
{mcpConfigTesting ? (
|
||||||
|
<>
|
||||||
|
<div className="w-4 h-4 animate-spin rounded-full border-2 border-blue-600 border-t-transparent mr-2" />
|
||||||
|
Testing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Play className="w-4 h-4 mr-2" />
|
||||||
|
Test Configuration
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
|
You can test your configuration to verify it's working correctly.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{mcpConfigTestResult && (
|
||||||
|
<div className={`p-3 rounded-lg text-sm ${
|
||||||
|
mcpConfigTestResult.success
|
||||||
|
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200 border border-green-200 dark:border-green-800'
|
||||||
|
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200 border border-red-200 dark:border-red-800'
|
||||||
|
}`}>
|
||||||
|
<div className="font-medium flex items-center gap-2">
|
||||||
|
{mcpConfigTestResult.success ? (
|
||||||
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{mcpConfigTestResult.message}
|
||||||
|
</div>
|
||||||
|
{mcpConfigTestResult.details && mcpConfigTestResult.details.length > 0 && (
|
||||||
|
<ul className="mt-2 space-y-1 text-xs">
|
||||||
|
{mcpConfigTestResult.details.map((detail, i) => (
|
||||||
|
<li key={i} className="flex items-start gap-1">
|
||||||
|
<span className="text-gray-400 mt-0.5">•</span>
|
||||||
|
<span>{detail}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-4">
|
||||||
|
<Button type="button" variant="outline" onClick={resetMcpForm}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={mcpLoading}
|
||||||
|
className="bg-purple-600 hover:bg-purple-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{mcpLoading ? 'Saving...' : (editingMcpServer ? 'Update Server' : 'Add Server')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user