feat: configure global MCP server settings in-app (#11)
* wip: basic implementation with claude support * edit existing MCP config, rather than append * extend implementation to support other executors * simplify backend implementation * lint * fix compile errors * decouple mcp server config from default executor selection * display executor config path in MCP settings box * write whole mcpServer object to config file * fmt * backend fmt * move MCP Server settings to seperate page * lint --------- Co-authored-by: couscous <couscous@runner.com>
This commit is contained in:
committed by
GitHub
parent
7620ba60fa
commit
a1c97f787e
@@ -5,6 +5,7 @@ import { Projects } from '@/pages/projects';
|
||||
import { ProjectTasks } from '@/pages/project-tasks';
|
||||
import { TaskAttemptComparePage } from '@/pages/task-attempt-compare';
|
||||
import { Settings } from '@/pages/Settings';
|
||||
import { McpServers } from '@/pages/McpServers';
|
||||
import { DisclaimerDialog } from '@/components/DisclaimerDialog';
|
||||
import { OnboardingDialog } from '@/components/OnboardingDialog';
|
||||
import { ConfigProvider, useConfig } from '@/components/config-provider';
|
||||
@@ -131,6 +132,7 @@ function AppContent() {
|
||||
element={<TaskAttemptComparePage />}
|
||||
/>
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/mcp-servers" element={<McpServers />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FolderOpen, Settings, HelpCircle } from 'lucide-react';
|
||||
import { FolderOpen, Settings, HelpCircle, Server } from 'lucide-react';
|
||||
import { Logo } from '@/components/logo';
|
||||
import { SupportDialog } from '@/components/support-dialog';
|
||||
|
||||
@@ -26,6 +26,18 @@ export function Navbar() {
|
||||
Projects
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
variant={
|
||||
location.pathname === '/mcp-servers' ? 'default' : 'ghost'
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
<Link to="/mcp-servers">
|
||||
<Server className="mr-2 h-4 w-4" />
|
||||
MCP Servers
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
variant={
|
||||
|
||||
357
frontend/src/pages/McpServers.tsx
Normal file
357
frontend/src/pages/McpServers.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { EXECUTOR_TYPES, EXECUTOR_LABELS } from 'shared/types';
|
||||
import { useConfig } from '@/components/config-provider';
|
||||
|
||||
export function McpServers() {
|
||||
const { config } = useConfig();
|
||||
const [mcpServers, setMcpServers] = useState('{}');
|
||||
const [mcpError, setMcpError] = useState<string | null>(null);
|
||||
const [mcpLoading, setMcpLoading] = useState(true);
|
||||
const [selectedMcpExecutor, setSelectedMcpExecutor] = useState<string>('');
|
||||
const [mcpApplying, setMcpApplying] = useState(false);
|
||||
const [mcpConfigPath, setMcpConfigPath] = useState<string>('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
// Initialize selected MCP executor when config loads
|
||||
useEffect(() => {
|
||||
if (config?.executor?.type && !selectedMcpExecutor) {
|
||||
setSelectedMcpExecutor(config.executor.type);
|
||||
}
|
||||
}, [config?.executor?.type, selectedMcpExecutor]);
|
||||
|
||||
// Load existing MCP configuration when selected executor changes
|
||||
useEffect(() => {
|
||||
const loadMcpServersForExecutor = async (executorType: string) => {
|
||||
// Reset state when loading
|
||||
setMcpLoading(true);
|
||||
setMcpError(null);
|
||||
|
||||
// Set default empty config based on executor type
|
||||
const defaultConfig =
|
||||
executorType === 'amp'
|
||||
? '{\n "amp.mcpServers": {\n }\n}'
|
||||
: '{\n "mcpServers": {\n }\n}';
|
||||
setMcpServers(defaultConfig);
|
||||
setMcpConfigPath('');
|
||||
|
||||
try {
|
||||
// Load MCP servers for the selected executor
|
||||
const response = await fetch(
|
||||
`/api/mcp-servers?executor=${executorType}`
|
||||
);
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
// Handle new response format with servers and config_path
|
||||
const data = result.data || {};
|
||||
const servers = data.servers || {};
|
||||
const configPath = data.config_path || '';
|
||||
|
||||
// Create the full configuration structure based on executor type
|
||||
let fullConfig;
|
||||
if (executorType === 'amp') {
|
||||
// For AMP, use the amp.mcpServers structure
|
||||
fullConfig = { 'amp.mcpServers': servers };
|
||||
} else {
|
||||
// For other executors, use the standard mcpServers structure
|
||||
fullConfig = { mcpServers: servers };
|
||||
}
|
||||
|
||||
const configJson = JSON.stringify(fullConfig, null, 2);
|
||||
setMcpServers(configJson);
|
||||
setMcpConfigPath(configPath);
|
||||
}
|
||||
} else {
|
||||
const result = await response.json();
|
||||
if (
|
||||
result.message &&
|
||||
result.message.includes('does not support MCP')
|
||||
) {
|
||||
// This executor doesn't support MCP - show warning message
|
||||
setMcpError(result.message);
|
||||
} else {
|
||||
console.warn('Failed to load MCP servers:', response.statusText);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading MCP servers:', err);
|
||||
} finally {
|
||||
setMcpLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load MCP servers for the selected MCP executor
|
||||
if (selectedMcpExecutor) {
|
||||
loadMcpServersForExecutor(selectedMcpExecutor);
|
||||
}
|
||||
}, [selectedMcpExecutor]);
|
||||
|
||||
const handleMcpServersChange = (value: string) => {
|
||||
setMcpServers(value);
|
||||
setMcpError(null);
|
||||
|
||||
// Validate JSON on change
|
||||
if (value.trim()) {
|
||||
try {
|
||||
const config = JSON.parse(value);
|
||||
// Validate that the config has the expected structure based on executor type
|
||||
if (selectedMcpExecutor === 'amp') {
|
||||
if (
|
||||
!config['amp.mcpServers'] ||
|
||||
typeof config['amp.mcpServers'] !== 'object'
|
||||
) {
|
||||
setMcpError(
|
||||
'AMP configuration must contain an "amp.mcpServers" object'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (!config.mcpServers || typeof config.mcpServers !== 'object') {
|
||||
setMcpError('Configuration must contain an "mcpServers" object');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setMcpError('Invalid JSON format');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyMcpServers = async () => {
|
||||
if (!selectedMcpExecutor) return;
|
||||
|
||||
setMcpApplying(true);
|
||||
setMcpError(null);
|
||||
|
||||
try {
|
||||
// Validate and save MCP configuration
|
||||
if (mcpServers.trim()) {
|
||||
try {
|
||||
const fullConfig = JSON.parse(mcpServers);
|
||||
|
||||
// Validate that the config has the expected structure based on executor type
|
||||
let mcpServersConfig;
|
||||
if (selectedMcpExecutor === 'amp') {
|
||||
if (
|
||||
!fullConfig['amp.mcpServers'] ||
|
||||
typeof fullConfig['amp.mcpServers'] !== 'object'
|
||||
) {
|
||||
throw new Error(
|
||||
'AMP configuration must contain an "amp.mcpServers" object'
|
||||
);
|
||||
}
|
||||
// Extract just the inner servers object for the API - backend will handle nesting
|
||||
mcpServersConfig = fullConfig['amp.mcpServers'];
|
||||
} else {
|
||||
if (
|
||||
!fullConfig.mcpServers ||
|
||||
typeof fullConfig.mcpServers !== 'object'
|
||||
) {
|
||||
throw new Error(
|
||||
'Configuration must contain an "mcpServers" object'
|
||||
);
|
||||
}
|
||||
// Extract just the mcpServers part for the API
|
||||
mcpServersConfig = fullConfig.mcpServers;
|
||||
}
|
||||
|
||||
const mcpResponse = await fetch(
|
||||
`/api/mcp-servers?executor=${selectedMcpExecutor}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(mcpServersConfig),
|
||||
}
|
||||
);
|
||||
|
||||
if (!mcpResponse.ok) {
|
||||
const errorData = await mcpResponse.json();
|
||||
throw new Error(errorData.message || 'Failed to save MCP servers');
|
||||
}
|
||||
|
||||
// Show success feedback
|
||||
setSuccess(true);
|
||||
setTimeout(() => setSuccess(false), 3000);
|
||||
} catch (mcpErr) {
|
||||
if (mcpErr instanceof SyntaxError) {
|
||||
setMcpError('Invalid JSON format');
|
||||
} else {
|
||||
setMcpError(
|
||||
mcpErr instanceof Error
|
||||
? mcpErr.message
|
||||
: 'Failed to save MCP servers'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setMcpError('Failed to apply MCP server configuration');
|
||||
console.error('Error applying MCP servers:', err);
|
||||
} finally {
|
||||
setMcpApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>Failed to load configuration.</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">MCP Servers</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Configure MCP servers to extend executor capabilities.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{mcpError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
MCP Configuration Error: {mcpError}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<Alert className="border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-950 dark:text-green-200">
|
||||
<AlertDescription className="font-medium">
|
||||
✓ MCP configuration saved successfully!
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Configuration</CardTitle>
|
||||
<CardDescription>
|
||||
Configure MCP servers for different executors to extend their
|
||||
capabilities with custom tools and resources.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mcp-executor">Executor</Label>
|
||||
<Select
|
||||
value={selectedMcpExecutor}
|
||||
onValueChange={(value: string) => setSelectedMcpExecutor(value)}
|
||||
>
|
||||
<SelectTrigger id="mcp-executor">
|
||||
<SelectValue placeholder="Select executor" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{EXECUTOR_TYPES.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{EXECUTOR_LABELS[type]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose which executor to configure MCP servers for.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{mcpError && mcpError.includes('does not support MCP') ? (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-950">
|
||||
<div className="flex">
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-amber-800 dark:text-amber-200">
|
||||
MCP Not Supported
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-amber-700 dark:text-amber-300">
|
||||
<p>{mcpError}</p>
|
||||
<p className="mt-1">
|
||||
To use MCP servers, please select a different executor
|
||||
(Claude, Amp, or Gemini) above.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mcp-servers">MCP Server Configuration</Label>
|
||||
<Textarea
|
||||
id="mcp-servers"
|
||||
placeholder={
|
||||
mcpLoading
|
||||
? 'Loading current configuration...'
|
||||
: '{\n "server-name": {\n "type": "stdio",\n "command": "your-command",\n "args": ["arg1", "arg2"]\n }\n}'
|
||||
}
|
||||
value={mcpLoading ? 'Loading...' : mcpServers}
|
||||
onChange={(e) => handleMcpServersChange(e.target.value)}
|
||||
disabled={mcpLoading}
|
||||
className="font-mono text-sm min-h-[300px]"
|
||||
/>
|
||||
{mcpError && !mcpError.includes('does not support MCP') && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400">
|
||||
{mcpError}
|
||||
</p>
|
||||
)}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{mcpLoading ? (
|
||||
'Loading current MCP server configuration...'
|
||||
) : (
|
||||
<span>
|
||||
Changes will be saved to:
|
||||
{mcpConfigPath && (
|
||||
<span className="ml-2 font-mono text-xs">
|
||||
{mcpConfigPath}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Sticky save button */}
|
||||
<div className="fixed bottom-0 left-0 right-0 bg-background/80 backdrop-blur-sm border-t p-4 z-10">
|
||||
<div className="container mx-auto max-w-4xl flex justify-end">
|
||||
<Button
|
||||
onClick={handleApplyMcpServers}
|
||||
disabled={mcpApplying || mcpLoading || !!mcpError || success}
|
||||
className={success ? 'bg-green-600 hover:bg-green-700' : ''}
|
||||
>
|
||||
{mcpApplying && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{success && <span className="mr-2">✓</span>}
|
||||
{success ? 'Settings Saved!' : 'Save Settings'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Spacer to prevent content from being hidden behind sticky button */}
|
||||
<div className="h-20"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -55,6 +55,7 @@ export function Settings() {
|
||||
setSuccess(false);
|
||||
|
||||
try {
|
||||
// Save the main configuration
|
||||
const success = await saveConfig();
|
||||
|
||||
if (success) {
|
||||
|
||||
Reference in New Issue
Block a user