diff --git a/src/renderer/src/components/settings/McpConfigFileRow.tsx b/src/renderer/src/components/settings/McpConfigFileRow.tsx new file mode 100644 index 000000000..7f640800e --- /dev/null +++ b/src/renderer/src/components/settings/McpConfigFileRow.tsx @@ -0,0 +1,117 @@ +import { AlertCircle, CheckCircle2 } from 'lucide-react' +import type { McpConfigInspection } from '../../../../shared/mcp-config' +import { Button } from '../ui/button' + +export type LoadedMcpConfigInspection = McpConfigInspection & { + absolutePath: string + readError?: string +} + +type McpConfigFileRowProps = { + config: LoadedMcpConfigInspection + onOpen: (config: LoadedMcpConfigInspection) => void +} + +function statusLabel(config: LoadedMcpConfigInspection): string { + if (config.readError) { + return 'Unreadable' + } + if (config.status === 'missing') { + return 'Not found' + } + if (config.status === 'invalid') { + return 'Invalid JSON' + } + if (config.servers.length === 0) { + return 'No servers' + } + return `${config.servers.length} server${config.servers.length === 1 ? '' : 's'}` +} + +function statusClassName(config: LoadedMcpConfigInspection): string { + if (config.readError || config.status === 'invalid') { + return 'border-destructive/30 bg-destructive/10 text-destructive' + } + if (config.status === 'valid' && config.servers.length > 0) { + return 'border-border/60 bg-background text-foreground' + } + return 'border-border/60 bg-muted/60 text-muted-foreground' +} + +function serverDetailLabel(server: LoadedMcpConfigInspection['servers'][number]): string { + if (server.transport === 'http') { + return server.url ?? 'HTTP server' + } + if (server.transport === 'stdio') { + return server.command ?? 'stdio server' + } + return server.issue ?? 'Invalid server' +} + +export function McpConfigFileRow({ config, onOpen }: McpConfigFileRowProps): React.JSX.Element { + return ( +
+
+ {config.status === 'valid' && !config.readError ? ( + + ) : ( + + )} +
+
+

{config.candidate.label}

+

+ {config.candidate.relativePath} +

+
+
+ + {statusLabel(config)} + + {config.exists ? ( + + ) : null} +
+ + {config.error || config.readError ? ( +

{config.readError ?? config.error}

+ ) : null} + + {config.servers.length > 0 ? ( +
+ {config.servers.map((server) => ( +
+
+
+ {server.name} + + {server.transport} + +
+

+ {serverDetailLabel(server)} +

+ {server.env && Object.keys(server.env).length > 0 ? ( +

+ env:{' '} + {Object.entries(server.env) + .map(([key, value]) => `${key}=${value}`) + .join(', ')} +

+ ) : null} +
+ {server.status} +
+ ))} +
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/settings/McpConfigSection.tsx b/src/renderer/src/components/settings/McpConfigSection.tsx index 7c662c301..6506e332d 100644 --- a/src/renderer/src/components/settings/McpConfigSection.tsx +++ b/src/renderer/src/components/settings/McpConfigSection.tsx @@ -1,23 +1,24 @@ import { useCallback, useEffect, useMemo, useState } from 'react' -import { AlertCircle, CheckCircle2, FileCode2, LoaderCircle, Plus, RefreshCw } from 'lucide-react' +import { AlertCircle, FileCode2, LoaderCircle, Plus, RefreshCw } from 'lucide-react' import { toast } from 'sonner' import type { Repo, Worktree } from '../../../../shared/types' import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' import { + canInspectLocalMcpConfigRoot, + getMcpConfigCandidateParentDir, + getMcpConfigParentDirs, inspectMcpConfigContent, MCP_CONFIG_CANDIDATES, MCP_STARTER_CONFIG, - type McpConfigInspection + selectExistingMcpConfigCandidates, + type McpConfigDirectoryEntry } from '../../../../shared/mcp-config' import { useAppStore } from '../../store' import { joinPath } from '../../lib/path' import { extractIpcErrorMessage } from '../../lib/ipc-error' import { Button } from '../ui/button' - -type LoadedInspection = McpConfigInspection & { - absolutePath: string - readError?: string -} +import { isWindowsUserAgent } from '../terminal-pane/pane-helpers' +import { McpConfigFileRow, type LoadedMcpConfigInspection } from './McpConfigFileRow' type McpConfigSectionProps = { repo: Repo @@ -30,50 +31,10 @@ function isMissingFileError(error: unknown): boolean { return /ENOENT|no such file|not found/i.test(message) } -function isNoFilesystemProviderMessage(message: string | undefined): boolean { - return message ? /no filesystem provider/i.test(message) : false -} - -function countServers(configs: LoadedInspection[]): number { +function countServers(configs: LoadedMcpConfigInspection[]): number { return configs.reduce((sum, config) => sum + config.servers.length, 0) } -function statusLabel(config: LoadedInspection): string { - if (config.readError) { - return 'Unreadable' - } - if (config.status === 'missing') { - return 'Not found' - } - if (config.status === 'invalid') { - return 'Invalid JSON' - } - if (config.servers.length === 0) { - return 'No servers' - } - return `${config.servers.length} server${config.servers.length === 1 ? '' : 's'}` -} - -function statusClassName(config: LoadedInspection): string { - if (config.readError || config.status === 'invalid') { - return 'border-destructive/30 bg-destructive/10 text-destructive' - } - if (config.status === 'valid' && config.servers.length > 0) { - return 'border-border/60 bg-background text-foreground' - } - return 'border-border/60 bg-muted/60 text-muted-foreground' -} - -function serverDetailLabel(server: LoadedInspection['servers'][number]): string { - if (server.transport === 'http') { - return server.url ?? 'HTTP server' - } - if (server.transport === 'stdio') { - return server.command ?? 'stdio server' - } - return server.issue ?? 'Invalid server' -} - export function McpConfigSection({ repo }: McpConfigSectionProps): React.JSX.Element { const openFile = useAppStore((state) => state.openFile) const setActiveView = useAppStore((state) => state.setActiveView) @@ -81,11 +42,18 @@ export function McpConfigSection({ repo }: McpConfigSectionProps): React.JSX.Ele const ensureWorktreeRootGroup = useAppStore((state) => state.ensureWorktreeRootGroup) const activeWorktreeId = useAppStore((state) => state.activeWorktreeId) const worktreesForRepo = useAppStore((state) => state.worktreesByRepo[repo.id] ?? EMPTY_WORKTREES) - const [configs, setConfigs] = useState([]) + const sshConnectionStatus = useAppStore((state) => + repo.connectionId ? state.sshConnectionStates.get(repo.connectionId)?.status : null + ) + const [configs, setConfigs] = useState([]) const [loading, setLoading] = useState(true) const [createConfirm, setCreateConfirm] = useState(false) + const [inspectionUnavailableMessage, setInspectionUnavailableMessage] = useState( + null + ) const connectionId = repo.connectionId ?? undefined + const isWindows = isWindowsUserAgent() const targetWorktree = useMemo(() => { if (activeWorktreeId && getRepoIdFromWorktreeId(activeWorktreeId) === repo.id) { return ( @@ -104,21 +72,15 @@ export function McpConfigSection({ repo }: McpConfigSectionProps): React.JSX.Ele const targetWorktreeId = targetWorktree.id const targetRootPath = targetWorktree.path const detectedCount = useMemo(() => configs.filter((config) => config.exists).length, [configs]) - const remoteFilesystemUnavailable = useMemo( - () => - Boolean(connectionId) && - configs.length > 0 && - configs.every((config) => isNoFilesystemProviderMessage(config.readError)), - [configs, connectionId] - ) + const inspectionUnavailable = inspectionUnavailableMessage !== null const visibleConfigs = useMemo( () => - remoteFilesystemUnavailable + inspectionUnavailable ? [] : configs.filter( (config) => config.exists || config.status === 'invalid' || config.readError ), - [configs, remoteFilesystemUnavailable] + [configs, inspectionUnavailable] ) const missingConfigs = useMemo( () => @@ -127,44 +89,133 @@ export function McpConfigSection({ repo }: McpConfigSectionProps): React.JSX.Ele ), [configs] ) + const missingInspections = useMemo( + () => + MCP_CONFIG_CANDIDATES.map( + (candidate): LoadedMcpConfigInspection => ({ + ...inspectMcpConfigContent(candidate, null), + absolutePath: joinPath(targetRootPath, candidate.relativePath) + }) + ), + [targetRootPath] + ) const serverCount = useMemo(() => countServers(configs), [configs]) - const canCreateStarter = detectedCount === 0 && !remoteFilesystemUnavailable + const canCreateStarter = detectedCount === 0 && !inspectionUnavailable const loadConfigs = useCallback(async (): Promise => { setLoading(true) - const next = await Promise.all( - MCP_CONFIG_CANDIDATES.map(async (candidate): Promise => { - const absolutePath = joinPath(targetRootPath, candidate.relativePath) - try { - const result = await window.api.fs.readFile({ filePath: absolutePath, connectionId }) - const inspection = inspectMcpConfigContent( - candidate, - result.isBinary ? '' : result.content + setInspectionUnavailableMessage(null) + + try { + if (connectionId && sshConnectionStatus !== 'connected') { + setConfigs(missingInspections) + setInspectionUnavailableMessage('Connect this SSH repo to inspect or add MCP configs.') + return + } + + if (!connectionId && !canInspectLocalMcpConfigRoot(targetRootPath, isWindows)) { + setConfigs(missingInspections) + setInspectionUnavailableMessage('This workspace path is not available from this host.') + return + } + + if (!connectionId && !(await window.api.shell.pathExists(targetRootPath))) { + setConfigs(missingInspections) + setInspectionUnavailableMessage('This workspace path is not available on disk.') + return + } + + const entriesByRelativeDir = new Map() + const rootEntries = await window.api.fs.readDir({ dirPath: targetRootPath, connectionId }) + entriesByRelativeDir.set('', rootEntries) + + const rootDirectoryNames = new Set( + rootEntries.filter((entry) => entry.isDirectory).map((entry) => entry.name) + ) + const unreadableParentDirMessages = new Map() + await Promise.all( + getMcpConfigParentDirs().map(async (relativeDir) => { + if (!rootDirectoryNames.has(relativeDir)) { + return + } + try { + const entries = await window.api.fs.readDir({ + dirPath: joinPath(targetRootPath, relativeDir), + connectionId + }) + entriesByRelativeDir.set(relativeDir, entries) + } catch (error) { + unreadableParentDirMessages.set( + relativeDir, + extractIpcErrorMessage(error, `Unable to inspect ${relativeDir}.`) + ) + } + }) + ) + + const existingRelativePaths = new Set( + selectExistingMcpConfigCandidates(entriesByRelativeDir).map( + (candidate) => candidate.relativePath + ) + ) + + const next = await Promise.all( + MCP_CONFIG_CANDIDATES.map(async (candidate): Promise => { + const absolutePath = joinPath(targetRootPath, candidate.relativePath) + const parentDirReadError = unreadableParentDirMessages.get( + getMcpConfigCandidateParentDir(candidate) ) - return { ...inspection, absolutePath } - } catch (error) { - if (isMissingFileError(error)) { + if (parentDirReadError) { + return { + ...inspectMcpConfigContent(candidate, null), + exists: false, + status: 'invalid', + absolutePath, + readError: parentDirReadError + } + } + + if (!existingRelativePaths.has(candidate.relativePath)) { return { ...inspectMcpConfigContent(candidate, null), absolutePath } } - return { - ...inspectMcpConfigContent(candidate, null), - exists: false, - status: 'invalid', - absolutePath, - readError: extractIpcErrorMessage(error, 'Unable to read config file.') + + try { + const result = await window.api.fs.readFile({ filePath: absolutePath, connectionId }) + const inspection = inspectMcpConfigContent( + candidate, + result.isBinary ? '' : result.content + ) + return { ...inspection, absolutePath } + } catch (error) { + if (isMissingFileError(error)) { + return { ...inspectMcpConfigContent(candidate, null), absolutePath } + } + return { + ...inspectMcpConfigContent(candidate, null), + exists: false, + status: 'invalid', + absolutePath, + readError: extractIpcErrorMessage(error, 'Unable to read config file.') + } } - } - }) - ) - setConfigs(next) - setLoading(false) - }, [connectionId, targetRootPath]) + }) + ) + setConfigs(next) + } catch (error) { + setConfigs(missingInspections) + setInspectionUnavailableMessage( + extractIpcErrorMessage(error, 'Unable to inspect MCP configs.') + ) + } finally { + setLoading(false) + } + }, [connectionId, isWindows, missingInspections, sshConnectionStatus, targetRootPath]) useEffect(() => { void loadConfigs() }, [loadConfigs]) - const handleOpen = (config: LoadedInspection): void => { + const handleOpen = (config: LoadedMcpConfigInspection): void => { setActiveWorktree(targetWorktreeId) const targetGroupId = ensureWorktreeRootGroup(targetWorktreeId) openFile( @@ -265,13 +316,13 @@ export function McpConfigSection({ repo }: McpConfigSectionProps): React.JSX.Ele
{visibleConfigs.length === 0 ? (
- {remoteFilesystemUnavailable ? ( + {inspectionUnavailable ? ( ) : ( )} - {remoteFilesystemUnavailable ? ( - Connect this SSH repo to inspect or add MCP configs. + {inspectionUnavailable ? ( + {inspectionUnavailableMessage} ) : ( No MCP config found. Add an empty workspace config when you want this repo to @@ -282,78 +333,16 @@ export function McpConfigSection({ repo }: McpConfigSectionProps): React.JSX.Ele ) : (
{visibleConfigs.map((config) => ( -
-
- {config.status === 'valid' && !config.readError ? ( - - ) : ( - - )} -
-
-

{config.candidate.label}

-

- {config.candidate.relativePath} -

-
-
- - {statusLabel(config)} - - {config.exists ? ( - - ) : null} -
- - {config.error || config.readError ? ( -

- {config.readError ?? config.error} -

- ) : null} - - {config.servers.length > 0 ? ( -
- {config.servers.map((server) => ( -
-
-
- {server.name} - - {server.transport} - -
-

- {serverDetailLabel(server)} -

- {server.env && Object.keys(server.env).length > 0 ? ( -

- env:{' '} - {Object.entries(server.env) - .map(([key, value]) => `${key}=${value}`) - .join(', ')} -

- ) : null} -
- - {server.status} - -
- ))} -
- ) : null} -
+ ))}
)} - {missingConfigs.length > 0 && !remoteFilesystemUnavailable ? ( + {missingConfigs.length > 0 && !inspectionUnavailable ? (

Checked

diff --git a/src/shared/mcp-config.test.ts b/src/shared/mcp-config.test.ts index f12ba2df2..a9f2b5412 100644 --- a/src/shared/mcp-config.test.ts +++ b/src/shared/mcp-config.test.ts @@ -1,9 +1,13 @@ import { describe, expect, it } from 'vitest' import { + canInspectLocalMcpConfigRoot, + getMcpConfigCandidateParentDir, + getMcpConfigParentDirs, inspectMcpConfigContent, maskMcpEnv, MCP_CONFIG_CANDIDATES, - MCP_STARTER_CONFIG + MCP_STARTER_CONFIG, + selectExistingMcpConfigCandidates } from './mcp-config' describe('mcp-config', () => { @@ -109,4 +113,40 @@ describe('mcp-config', () => { servers: [] }) }) + + it('plans directory discovery before reading candidate files', () => { + expect(getMcpConfigParentDirs()).toEqual(['.cursor', '.claude']) + expect( + MCP_CONFIG_CANDIDATES.map((candidate) => getMcpConfigCandidateParentDir(candidate)) + ).toEqual(['', '.cursor', '', '.claude']) + + const entriesByRelativeDir = new Map([ + [ + '', + [ + { name: '.mcp.json', isDirectory: false }, + { name: '.cursor', isDirectory: true }, + { name: '.claude', isDirectory: false } + ] + ], + ['.cursor', [{ name: 'mcp.json', isDirectory: false }]] + ]) + + expect( + selectExistingMcpConfigCandidates(entriesByRelativeDir).map((entry) => entry.label) + ).toEqual(['Workspace', 'Cursor']) + }) + + it('rejects Windows-only local roots on non-Windows hosts', () => { + expect(canInspectLocalMcpConfigRoot('C:\\repo', false)).toBe(false) + expect(canInspectLocalMcpConfigRoot('\\\\wsl.localhost\\Ubuntu\\home\\me\\repo', false)).toBe( + false + ) + expect(canInspectLocalMcpConfigRoot('//wsl.localhost/Ubuntu/home/me/repo', false)).toBe(false) + expect(canInspectLocalMcpConfigRoot('/Users/me/repo', false)).toBe(true) + expect(canInspectLocalMcpConfigRoot('\\\\wsl.localhost\\Ubuntu\\home\\me\\repo', true)).toBe( + true + ) + expect(canInspectLocalMcpConfigRoot('//wsl.localhost/Ubuntu/home/me/repo', true)).toBe(true) + }) }) diff --git a/src/shared/mcp-config.ts b/src/shared/mcp-config.ts index 5a81ac2b2..11ef0e669 100644 --- a/src/shared/mcp-config.ts +++ b/src/shared/mcp-config.ts @@ -7,6 +7,11 @@ export type McpConfigCandidate = { serversPath: string[] } +export type McpConfigDirectoryEntry = { + name: string + isDirectory: boolean +} + export type McpServerTransport = 'stdio' | 'http' | 'unknown' export type McpServerStatus = 'enabled' | 'disabled' | 'invalid' @@ -60,6 +65,41 @@ export const MCP_STARTER_CONFIG = `{ } ` +export function getMcpConfigParentDirs( + candidates: readonly McpConfigCandidate[] = MCP_CONFIG_CANDIDATES +): string[] { + return Array.from( + new Set( + candidates + .map((candidate) => getRelativeParentDir(candidate.relativePath)) + .filter((parentDir) => parentDir !== '') + ) + ) +} + +export function getMcpConfigCandidateParentDir(candidate: McpConfigCandidate): string { + return getRelativeParentDir(candidate.relativePath) +} + +export function selectExistingMcpConfigCandidates( + entriesByRelativeDir: ReadonlyMap, + candidates: readonly McpConfigCandidate[] = MCP_CONFIG_CANDIDATES +): McpConfigCandidate[] { + return candidates.filter((candidate) => { + const parentDir = getRelativeParentDir(candidate.relativePath) + const basename = getRelativeBasename(candidate.relativePath) + const entries = entriesByRelativeDir.get(parentDir) ?? [] + return entries.some((entry) => entry.name === basename && !entry.isDirectory) + }) +} + +export function canInspectLocalMcpConfigRoot(rootPath: string, isWindowsHost: boolean): boolean { + if (isWindowsHost) { + return true + } + return !/^(?:[A-Za-z]:[\\/]|[\\/]{2}[^\\/]+[\\/][^\\/]+)/.test(rootPath) +} + const SENSITIVE_ENV_KEY_PATTERN = /(api[_-]?key|auth|bearer|cookie|credential|password|private[_-]?key|secret|session|token)/i const SENSITIVE_ENV_VALUE_PATTERN = @@ -115,6 +155,18 @@ export function maskMcpEnv(env: unknown): Record | undefined { return masked } +function getRelativeParentDir(relativePath: string): string { + const normalizedPath = relativePath.replace(/\\/g, '/') + const separatorIndex = normalizedPath.lastIndexOf('/') + return separatorIndex === -1 ? '' : normalizedPath.slice(0, separatorIndex) +} + +function getRelativeBasename(relativePath: string): string { + const normalizedPath = relativePath.replace(/\\/g, '/') + const separatorIndex = normalizedPath.lastIndexOf('/') + return separatorIndex === -1 ? normalizedPath : normalizedPath.slice(separatorIndex + 1) +} + function extractObjectAtPath( value: unknown, pathSegments: string[]