bolt.diy/app/components/chat/MCPConnection.tsx
Nirmal Arya c53e3214c1 Fix MCP icon visibility in dark mode
Added proper CSS filters to make MCP icon visible in both light and dark modes.
The icon now uses brightness and invert filters to adapt to the theme.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-25 11:27:48 -04:00

425 lines
16 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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'],
},
'sequential-thinking': {
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-sequential-thinking'],
},
'local-sse-server': {
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 filter brightness-0 dark:brightness-100 dark:invert" 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 filter brightness-0 dark:brightness-100 dark:invert" 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">
<div className="mb-2 p-2 bg-bolt-elements-background-depth-1 rounded border border-bolt-elements-borderColor">
<strong> Server Types:</strong> Buildify supports both stdio (npx, uvx, docker) and SSE-based MCP servers.
Stdio servers require Node.js runtime compatibility.
</div>
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>
);
}