mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-06-26 18:26:38 +00:00
417 lines
16 KiB
TypeScript
417 lines
16 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { classNames } from '~/utils/classNames';
|
|
import { Dialog, DialogRoot, DialogClose, DialogTitle, DialogButton } from '~/components/ui/Dialog';
|
|
import { useMCPConfig, type MCPConfig } from '~/lib/hooks/useMCPConfig';
|
|
import { IconButton } from '~/components/ui/IconButton';
|
|
|
|
// Example MCP configuration that users can load
|
|
const EXAMPLE_MCP_CONFIG: MCPConfig = {
|
|
mcpServers: {
|
|
everything: {
|
|
command: 'npx',
|
|
args: ['-y', '@modelcontextprotocol/server-everything'],
|
|
},
|
|
git: {
|
|
command: 'uvx',
|
|
args: ['mcp-server-git'],
|
|
},
|
|
'remote-sse': {
|
|
type: 'sse',
|
|
url: 'http://localhost:8000/sse',
|
|
},
|
|
},
|
|
};
|
|
|
|
type ServerStatus = Record<string, boolean>;
|
|
type ServerErrors = Record<string, string>;
|
|
type ServerTools = Record<string, any>;
|
|
|
|
export function McpConnection() {
|
|
const { config, updateConfig, lastUpdate, isLoading } = useMCPConfig();
|
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
|
const [configText, setConfigText] = useState('');
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [serverStatus, setServerStatus] = useState<ServerStatus>({});
|
|
const [serverErrors, setServerErrors] = useState<ServerErrors>({});
|
|
const [serverTools, setServerTools] = useState<ServerTools>({});
|
|
const [checkingServers, setCheckingServers] = useState(false);
|
|
const [configTextParsed, setConfigTextParsed] = useState<MCPConfig | null>(null);
|
|
const [expandedServer, setExpandedServer] = useState<string | null>(null);
|
|
const [isInitialLoad, setIsInitialLoad] = useState(true);
|
|
|
|
// Initialize config text from config
|
|
useEffect(() => {
|
|
if (config) {
|
|
setConfigText(JSON.stringify(config, null, 2));
|
|
setConfigTextParsed(config);
|
|
} else {
|
|
setConfigText(JSON.stringify({ mcpServers: {} }, null, 2));
|
|
setConfigTextParsed({ mcpServers: {} });
|
|
}
|
|
}, [config, lastUpdate]);
|
|
|
|
// Check server availability on initial load
|
|
useEffect(() => {
|
|
if (isInitialLoad && configTextParsed?.mcpServers && Object.keys(configTextParsed.mcpServers).length > 0) {
|
|
checkServerAvailability();
|
|
setIsInitialLoad(false);
|
|
}
|
|
}, [configTextParsed, isInitialLoad]);
|
|
|
|
// Reset initial load flag when config is updated externally
|
|
useEffect(() => {
|
|
setIsInitialLoad(true);
|
|
}, [lastUpdate]);
|
|
|
|
// Parse the textarea content when it changes
|
|
useEffect(() => {
|
|
try {
|
|
const parsed = JSON.parse(configText) as MCPConfig;
|
|
setConfigTextParsed(parsed);
|
|
|
|
if (error?.includes('JSON')) {
|
|
setError(null);
|
|
}
|
|
} catch (e) {
|
|
setConfigTextParsed(null);
|
|
setError(`Invalid JSON format: ${e instanceof Error ? e.message : String(e)}`);
|
|
}
|
|
}, [configText, error]);
|
|
|
|
const handleSave = () => {
|
|
try {
|
|
const parsed = JSON.parse(configText) as MCPConfig;
|
|
updateConfig(parsed);
|
|
setError(null);
|
|
setIsDialogOpen(false);
|
|
} catch {
|
|
setError('Invalid JSON format');
|
|
}
|
|
};
|
|
|
|
const handleLoadExample = () => {
|
|
setConfigText(JSON.stringify(EXAMPLE_MCP_CONFIG, null, 2));
|
|
setError(null);
|
|
};
|
|
|
|
const checkServerAvailability = async () => {
|
|
try {
|
|
const parsed = JSON.parse(configText) as MCPConfig;
|
|
|
|
if (!parsed?.mcpServers) {
|
|
setError('No servers configured or invalid configuration');
|
|
return;
|
|
}
|
|
|
|
setCheckingServers(true);
|
|
setError(null);
|
|
setServerErrors({});
|
|
setServerTools({});
|
|
|
|
try {
|
|
const response = await fetch('/api/mcp-check', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ mcpServers: parsed.mcpServers }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Server responded with ${response.status}: ${response.statusText}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
if (typeof data === 'object' && data !== null && 'serverStatus' in data && 'serverErrors' in data) {
|
|
setServerStatus(data.serverStatus as ServerStatus);
|
|
setServerErrors((data.serverErrors as ServerErrors) || ({} as ServerErrors));
|
|
|
|
if ('serverTools' in data) {
|
|
setServerTools((data.serverTools as ServerTools) || ({} as ServerTools));
|
|
}
|
|
} else {
|
|
throw new Error('Invalid response format from server');
|
|
}
|
|
} catch (e) {
|
|
setError(`Failed to check server availability: ${e instanceof Error ? e.message : String(e)}`);
|
|
} finally {
|
|
setCheckingServers(false);
|
|
}
|
|
} catch (e) {
|
|
setError(`Invalid JSON format: ${e instanceof Error ? e.message : String(e)}`);
|
|
setCheckingServers(false);
|
|
}
|
|
};
|
|
|
|
const toggleServerExpanded = (serverName: string) => {
|
|
setExpandedServer(expandedServer === serverName ? null : serverName);
|
|
};
|
|
|
|
const handleDialogOpen = (open: boolean) => {
|
|
setIsDialogOpen(open);
|
|
|
|
if (
|
|
open &&
|
|
configTextParsed?.mcpServers &&
|
|
Object.keys(configTextParsed.mcpServers).length > 0 &&
|
|
Object.keys(serverStatus).length === 0
|
|
) {
|
|
checkServerAvailability();
|
|
}
|
|
};
|
|
|
|
const formatToolSchema = (toolName: string, toolSchema: any) => {
|
|
if (!toolSchema) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const parameters = toolSchema.parameters?.properties || {};
|
|
|
|
return (
|
|
<div className="mt-2 ml-4 p-2 rounded-md bg-bolt-elements-background-depth-2 text-xs font-mono">
|
|
<div className="font-medium mb-1">{toolName}</div>
|
|
<div className="text-bolt-elements-textSecondary">{toolSchema.description || 'No description available'}</div>
|
|
|
|
{Object.keys(parameters).length > 0 && (
|
|
<div className="mt-2">
|
|
<div className="font-medium mb-1">Parameters:</div>
|
|
<div className="ml-2 space-y-1">
|
|
{Object.entries(parameters).map(([paramName, paramDetails]: [string, any]) => (
|
|
<div key={paramName}>
|
|
<span className="text-bolt-elements-textAccent">{paramName}</span>
|
|
{paramDetails.required && <span className="text-red-500 ml-1">*</span>}
|
|
<span className="text-bolt-elements-textSecondary ml-2">
|
|
{paramDetails.type || 'any'}
|
|
{paramDetails.description ? ` - ${paramDetails.description}` : ''}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
} catch (e) {
|
|
return (
|
|
<div className="mt-2 ml-4 p-2 rounded-md bg-red-100 dark:bg-red-900 text-xs">
|
|
Error parsing tool schema: {e instanceof Error ? e.message : String(e)}
|
|
</div>
|
|
);
|
|
}
|
|
};
|
|
|
|
const StatusBadge = ({ status }: { status: 'checking' | 'available' | 'unavailable' }) => {
|
|
const badgeStyles = {
|
|
checking: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
|
|
available: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
|
unavailable: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
|
|
};
|
|
|
|
const text = {
|
|
checking: 'Checking...',
|
|
available: 'Available',
|
|
unavailable: 'Unavailable',
|
|
};
|
|
|
|
const icon =
|
|
status === 'checking' ? (
|
|
<div className="i-svg-spinners:90-ring-with-bg w-3 h-3 text-bolt-elements-loader-progress animate-spin" />
|
|
) : null;
|
|
|
|
return (
|
|
<span className={`px-2 py-0.5 rounded-full text-xs font-medium flex items-center gap-1 ${badgeStyles[status]}`}>
|
|
{icon}
|
|
{text[status]}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
const renderServerList = () => {
|
|
if (!configTextParsed?.mcpServers) {
|
|
return <p className="text-sm text-bolt-elements-textSecondary">Invalid configuration or no servers defined</p>;
|
|
}
|
|
|
|
const serverEntries = Object.entries(configTextParsed.mcpServers);
|
|
|
|
if (serverEntries.length === 0) {
|
|
return <p className="text-sm text-bolt-elements-textSecondary">No MCP servers configured</p>;
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
{serverEntries.map(([serverName, serverConfig]) => {
|
|
const isAvailable = serverStatus[serverName];
|
|
const statusKnown = serverName in serverStatus;
|
|
const errorMessage = serverErrors[serverName];
|
|
const serverToolsData = serverTools[serverName];
|
|
const isExpanded = expandedServer === serverName;
|
|
|
|
return (
|
|
<div key={serverName} className="flex flex-col py-1 px-2 rounded-md bg-bolt-elements-background-depth-1">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<div
|
|
onClick={() => toggleServerExpanded(serverName)}
|
|
className="flex items-center gap-1 text-bolt-elements-textPrimary"
|
|
>
|
|
<div className={`i-ph:${isExpanded ? 'caret-down' : 'caret-right'} w-3 h-3`} />
|
|
<span className="font-medium">{serverName}</span>
|
|
</div>
|
|
{serverConfig.type === 'sse' ? (
|
|
<span className="text-xs text-bolt-elements-textSecondary">SSE: {serverConfig.url}</span>
|
|
) : (
|
|
<span className="text-xs text-bolt-elements-textSecondary">
|
|
{serverConfig.command} {serverConfig.args?.join(' ')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{checkingServers ? (
|
|
<StatusBadge status="checking" />
|
|
) : (
|
|
statusKnown && <StatusBadge status={isAvailable ? 'available' : 'unavailable'} />
|
|
)}
|
|
</div>
|
|
|
|
{/* Display error message if server is unavailable */}
|
|
{statusKnown && !isAvailable && errorMessage && (
|
|
<div className="mt-1 ml-4 text-xs text-red-600 dark:text-red-400">Error: {errorMessage}</div>
|
|
)}
|
|
|
|
{/* Display tool schemas if server is expanded */}
|
|
{isExpanded && statusKnown && isAvailable && serverToolsData && (
|
|
<div className="mt-2">
|
|
<div className="text-xs font-medium ml-2 mb-1">Available Tools:</div>
|
|
{Object.keys(serverToolsData).length === 0 ? (
|
|
<div className="ml-4 text-xs text-bolt-elements-textSecondary">No tools available</div>
|
|
) : (
|
|
<div className="mt-1 space-y-2">
|
|
{Object.entries(serverToolsData).map(([toolName, toolSchema]) => (
|
|
<div key={toolName}>{formatToolSchema(toolName, toolSchema)}</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="relative">
|
|
<div className="flex">
|
|
<IconButton onClick={() => setIsDialogOpen(!isDialogOpen)} title="Configure MCP" className="transition-all">
|
|
{isLoading ? (
|
|
<div className="i-svg-spinners:90-ring-with-bg text-bolt-elements-loader-progress text-xl animate-spin" />
|
|
) : (
|
|
<img className="w-4 h-4" height="20" width="20" src="/icons/mcp.svg" alt="MCP" />
|
|
)}
|
|
</IconButton>
|
|
</div>
|
|
|
|
<DialogRoot open={isDialogOpen} onOpenChange={handleDialogOpen}>
|
|
{isDialogOpen && (
|
|
<Dialog className="max-w-4xl w-full p-6">
|
|
<div className="space-y-4 max-h-[80vh] overflow-y-auto pr-2">
|
|
<DialogTitle>
|
|
<img className="w-5 h-5" height="24" width="24" src="/icons/mcp.svg" alt="MCP" />
|
|
MCP Configuration
|
|
</DialogTitle>
|
|
|
|
<div className="space-y-4">
|
|
<div>
|
|
<div className="flex justify-between items-center mb-2">
|
|
<label className="text-sm text-bolt-elements-textSecondary">Configured MCP Servers</label>
|
|
<button
|
|
onClick={checkServerAvailability}
|
|
disabled={
|
|
checkingServers ||
|
|
!configTextParsed ||
|
|
Object.keys(configTextParsed?.mcpServers || {}).length === 0
|
|
}
|
|
className={classNames(
|
|
'px-3 py-1 rounded-md text-xs flex items-center gap-1',
|
|
'border border-bolt-elements-borderColor',
|
|
'hover:bg-bolt-elements-background-depth-1',
|
|
'disabled:opacity-50 disabled:cursor-not-allowed',
|
|
)}
|
|
>
|
|
{checkingServers ? (
|
|
<div className="i-svg-spinners:90-ring-with-bg w-3 h-3 text-bolt-elements-loader-progress animate-spin" />
|
|
) : (
|
|
<div className="i-ph:arrow-counter-clockwise w-3 h-3" />
|
|
)}
|
|
Check availability
|
|
</button>
|
|
</div>
|
|
{renderServerList()}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm text-bolt-elements-textSecondary mb-2">Configuration JSON</label>
|
|
<textarea
|
|
value={configText}
|
|
onChange={(e) => setConfigText(e.target.value)}
|
|
className={classNames(
|
|
'w-full px-3 py-2 rounded-lg text-sm font-mono h-72',
|
|
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
|
|
'border',
|
|
error ? 'border-bolt-elements-icon-error' : 'border-[#E5E5E5] dark:border-[#333333]',
|
|
'text-bolt-elements-textPrimary',
|
|
'focus:outline-none focus:ring-1 focus:ring-bolt-elements-focus',
|
|
)}
|
|
/>
|
|
|
|
{error && <p className="mt-2 text-sm text-bolt-elements-icon-error">{error}</p>}
|
|
|
|
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
|
|
The MCP configuration format is identical to the one used in Claude Desktop.
|
|
<a
|
|
href="https://modelcontextprotocol.io/examples"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-bolt-elements-link hover:underline inline-flex items-center gap-1"
|
|
>
|
|
View example servers
|
|
<div className="i-ph:arrow-square-out w-4 h-4" />
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-between gap-2 mt-6">
|
|
<button
|
|
onClick={handleLoadExample}
|
|
className="px-4 py-2 rounded-lg text-sm border border-bolt-elements-borderColor
|
|
bg-bolt-elements-background-depth-2 text-bolt-elements-textSecondary
|
|
hover:bg-bolt-elements-background-depth-3"
|
|
>
|
|
Load Example
|
|
</button>
|
|
|
|
<div className="flex gap-2">
|
|
<DialogClose asChild>
|
|
<DialogButton type="secondary">Cancel</DialogButton>
|
|
</DialogClose>
|
|
<button
|
|
onClick={handleSave}
|
|
className="px-4 py-2 rounded-lg text-sm flex items-center gap-2
|
|
bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent
|
|
hover:bg-bolt-elements-item-backgroundActive"
|
|
>
|
|
<div className="i-ph:floppy-disk w-4 h-4" />
|
|
Save Configuration
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Dialog>
|
|
)}
|
|
</DialogRoot>
|
|
</div>
|
|
);
|
|
}
|