diff --git a/sidecar/src/Eryx.AgentHost/Contracts/ProtocolModels.cs b/sidecar/src/Eryx.AgentHost/Contracts/ProtocolModels.cs index da8fd16..38a25d9 100644 --- a/sidecar/src/Eryx.AgentHost/Contracts/ProtocolModels.cs +++ b/sidecar/src/Eryx.AgentHost/Contracts/ProtocolModels.cs @@ -187,8 +187,10 @@ public sealed class RunTurnMcpServerConfigDto public int? TimeoutMs { get; init; } public string? Command { get; init; } public IReadOnlyList? Args { get; init; } + public IReadOnlyDictionary? Env { get; init; } public string? Cwd { get; init; } public string? Url { get; init; } + public IReadOnlyDictionary? Headers { get; init; } } public sealed class RunTurnLspProfileConfigDto diff --git a/sidecar/src/Eryx.AgentHost/Services/SessionToolingBundle.cs b/sidecar/src/Eryx.AgentHost/Services/SessionToolingBundle.cs index 3576faa..cc03ef5 100644 --- a/sidecar/src/Eryx.AgentHost/Services/SessionToolingBundle.cs +++ b/sidecar/src/Eryx.AgentHost/Services/SessionToolingBundle.cs @@ -98,6 +98,7 @@ internal sealed class SessionToolingBundle : IAsyncDisposable Timeout = server.TimeoutMs, Command = server.Command, Args = server.Args?.ToList() ?? [], + Env = server.Env is null ? null : new Dictionary(server.Env, StringComparer.Ordinal), Cwd = string.IsNullOrWhiteSpace(server.Cwd) ? null : server.Cwd, Tools = ResolveTools(server), }; @@ -116,6 +117,7 @@ internal sealed class SessionToolingBundle : IAsyncDisposable Type = server.Transport, Timeout = server.TimeoutMs, Url = server.Url, + Headers = server.Headers is null ? null : new Dictionary(server.Headers, StringComparer.Ordinal), Tools = ResolveTools(server), }; } diff --git a/sidecar/tests/Eryx.AgentHost.Tests/SessionToolingBundleTests.cs b/sidecar/tests/Eryx.AgentHost.Tests/SessionToolingBundleTests.cs index db34bec..cef29ce 100644 --- a/sidecar/tests/Eryx.AgentHost.Tests/SessionToolingBundleTests.cs +++ b/sidecar/tests/Eryx.AgentHost.Tests/SessionToolingBundleTests.cs @@ -18,6 +18,10 @@ public sealed class SessionToolingBundleTests Transport = "local", Command = "node", Args = ["server.js", "--stdio"], + Env = new Dictionary + { + ["DEBUG"] = "true", + }, Cwd = @"C:\workspace\repo", Tools = ["git.status"], TimeoutMs = 1500, @@ -28,6 +32,10 @@ public sealed class SessionToolingBundleTests Name = "Remote MCP", Transport = "http", Url = "https://example.com/mcp", + Headers = new Dictionary + { + ["Authorization"] = "Bearer token", + }, Tools = ["*"], }, ]; @@ -38,6 +46,9 @@ public sealed class SessionToolingBundleTests Assert.Equal("local", localConfig.Type); Assert.Equal("node", localConfig.Command); Assert.Equal(["server.js", "--stdio"], localConfig.Args); + KeyValuePair localEnv = Assert.Single(localConfig.Env!); + Assert.Equal("DEBUG", localEnv.Key); + Assert.Equal("true", localEnv.Value); Assert.Equal(@"C:\workspace\repo", localConfig.Cwd); Assert.Equal(["git.status"], localConfig.Tools); Assert.Equal(1500, localConfig.Timeout); @@ -45,6 +56,9 @@ public sealed class SessionToolingBundleTests McpRemoteServerConfig remoteConfig = Assert.IsType(configurations["Remote MCP"]); Assert.Equal("http", remoteConfig.Type); Assert.Equal("https://example.com/mcp", remoteConfig.Url); + KeyValuePair remoteHeader = Assert.Single(remoteConfig.Headers!); + Assert.Equal("Authorization", remoteHeader.Key); + Assert.Equal("Bearer token", remoteHeader.Value); Assert.Equal(["*"], remoteConfig.Tools); } diff --git a/src/main/EryxAppService.ts b/src/main/EryxAppService.ts index 452b3e1..e6c300c 100644 --- a/src/main/EryxAppService.ts +++ b/src/main/EryxAppService.ts @@ -24,6 +24,12 @@ import { type ReasoningEffort, validatePatternDefinition, } from '@shared/domain/pattern'; +import { + applyDiscoveredMcpServerStatus, + normalizeDiscoveredToolingState, + type DiscoveredToolingState, + type DiscoveredToolingStatus, +} from '@shared/domain/discoveredTooling'; import { approvalPolicyRequiresCheckpoint, dequeuePendingApprovalState, @@ -71,6 +77,8 @@ import { createSessionToolingSelection, listApprovalToolNames, normalizeTheme, + resolveProjectToolingSettings, + resolveWorkspaceToolingSettings, type AppearanceTheme, type LspProfileDefinition, type McpServerDefinition, @@ -86,7 +94,11 @@ import { mergeStreamingText } from '@shared/utils/streamingText'; import { WorkspaceRepository } from '@main/persistence/workspaceRepository'; import { SecretStore } from '@main/secrets/secretStore'; -import { SidecarClient } from '@main/sidecar/sidecarProcess'; +import { ConfigScannerRegistry } from '@main/services/configScanner'; +import { + SIDECAR_STOPPED_BEFORE_COMPLETION_MESSAGE, + SidecarClient, +} from '@main/sidecar/sidecarProcess'; import { GitService } from '@main/git/gitService'; import { buildRunTurnToolingConfig as buildSessionToolingConfig, @@ -104,6 +116,8 @@ type PendingApprovalHandle = { resolve: (decision: ApprovalDecision) => void | Promise; }; +type DiscoveredToolingResolution = 'accept' | 'dismiss'; + function isBuiltinPattern(patternId: string): boolean { return patternId.startsWith('pattern-'); } @@ -118,14 +132,20 @@ function equalStringArrays(left?: readonly string[], right?: readonly string[]): return normalizedLeft.every((value, index) => value === normalizedRight[index]); } +function isSidecarStoppedBeforeCompletionError(error: unknown): error is Error { + return error instanceof Error && error.message === SIDECAR_STOPPED_BEFORE_COMPLETION_MESSAGE; +} + export class EryxAppService extends EventEmitter { private readonly workspaceRepository = new WorkspaceRepository(); private readonly sidecar = new SidecarClient(); private readonly secretStore = new SecretStore(); private readonly gitService = new GitService(); + private readonly configScanner = new ConfigScannerRegistry(); private readonly pendingApprovalHandles = new Map(); private workspace?: WorkspaceState; private sidecarCapabilities?: SidecarCapabilities; + private sidecarCapabilitiesPromise?: Promise; private didScheduleInitialProjectGitRefresh = false; async describeSidecarCapabilities(): Promise { @@ -139,8 +159,23 @@ export class EryxAppService extends EventEmitter { async loadWorkspace(): Promise { if (!this.workspace) { this.workspace = await this.workspaceRepository.load(); + const selectedProjectId = this.workspace.selectedProjectId; + const selectedProject = selectedProjectId + ? this.workspace.projects.find((project) => project.id === selectedProjectId) + : undefined; + const didSyncUserTooling = await this.syncUserDiscoveredTooling(this.workspace); + const didSyncProjectTooling = selectedProject + ? await this.syncProjectDiscoveredTooling(this.workspace, selectedProject) + : false; + const didPruneSelections = this.pruneUnavailableSessionToolingSelections(this.workspace); const didPruneApprovalTools = await this.pruneUnavailableApprovalTools(this.workspace); - if (didPruneApprovalTools || this.failInterruptedPendingApprovals(this.workspace)) { + if ( + didSyncUserTooling + || didSyncProjectTooling + || didPruneSelections + || didPruneApprovalTools + || this.failInterruptedPendingApprovals(this.workspace) + ) { await this.workspaceRepository.save(this.workspace); } } @@ -177,6 +212,7 @@ export class EryxAppService extends EventEmitter { this.workspace = undefined; this.sidecarCapabilities = undefined; + this.sidecarCapabilitiesPromise = undefined; this.didScheduleInitialProjectGitRefresh = false; return this.loadWorkspace(); @@ -197,6 +233,11 @@ export class EryxAppService extends EventEmitter { const existing = workspace.projects.find((project) => project.path === folderPath); if (existing) { workspace.selectedProjectId = existing.id; + const didSyncProjectTooling = await this.syncProjectDiscoveredTooling(workspace, existing); + if (didSyncProjectTooling) { + this.pruneUnavailableSessionToolingSelections(workspace); + await this.pruneUnavailableApprovalTools(workspace); + } return this.persistAndBroadcast(workspace); } @@ -210,6 +251,7 @@ export class EryxAppService extends EventEmitter { workspace.projects.push(project); workspace.selectedProjectId = project.id; + await this.syncProjectDiscoveredTooling(workspace, project); return this.persistAndBroadcast(workspace); } @@ -236,6 +278,49 @@ export class EryxAppService extends EventEmitter { return this.persistAndBroadcast(workspace); } + async resolveWorkspaceDiscoveredTooling( + serverIds: string[], + resolution: DiscoveredToolingResolution, + ): Promise { + const workspace = await this.loadWorkspace(); + workspace.settings.discoveredUserTooling = applyDiscoveredMcpServerStatus( + workspace.settings.discoveredUserTooling, + serverIds, + this.resolveDiscoveredToolingStatus(resolution), + ); + + this.pruneUnavailableSessionToolingSelections(workspace); + await this.pruneUnavailableApprovalTools(workspace); + return this.persistAndBroadcast(workspace); + } + + async rescanProjectConfigs(projectId: string): Promise { + const workspace = await this.loadWorkspace(); + const project = this.requireProject(workspace, projectId); + await this.syncProjectDiscoveredTooling(workspace, project); + this.pruneUnavailableSessionToolingSelections(workspace); + await this.pruneUnavailableApprovalTools(workspace); + return this.persistAndBroadcast(workspace); + } + + async resolveProjectDiscoveredTooling( + projectId: string, + serverIds: string[], + resolution: DiscoveredToolingResolution, + ): Promise { + const workspace = await this.loadWorkspace(); + const project = this.requireProject(workspace, projectId); + project.discoveredTooling = applyDiscoveredMcpServerStatus( + project.discoveredTooling, + serverIds, + this.resolveDiscoveredToolingStatus(resolution), + ); + + this.pruneUnavailableSessionToolingSelections(workspace); + await this.pruneUnavailableApprovalTools(workspace); + return this.persistAndBroadcast(workspace); + } + async savePattern(pattern: PatternDefinition): Promise { const workspace = await this.loadWorkspace(); const knownApprovalToolNames = await this.listKnownApprovalToolNames(workspace); @@ -675,7 +760,7 @@ export class EryxAppService extends EventEmitter { ): Promise { const workspace = await this.loadWorkspace(); const session = this.requireSession(workspace, sessionId); - this.requireProject(workspace, session.projectId); + const project = this.requireProject(workspace, session.projectId); if (session.status === 'running') { throw new Error('Wait for the current response to finish before changing session tools.'); @@ -685,7 +770,10 @@ export class EryxAppService extends EventEmitter { enabledMcpServerIds, enabledLspProfileIds, }); - validateSessionToolingSelectionIds(workspace.settings.tooling, selection); + validateSessionToolingSelectionIds( + resolveProjectToolingSettings(workspace.settings, project.discoveredTooling), + selection, + ); session.tooling = selection; session.updatedAt = nowIso(); @@ -698,7 +786,7 @@ export class EryxAppService extends EventEmitter { ): Promise { const workspace = await this.loadWorkspace(); const session = this.requireSession(workspace, sessionId); - this.requireProject(workspace, session.projectId); + const project = this.requireProject(workspace, session.projectId); if (session.status === 'running') { throw new Error('Wait for the current response to finish before changing session approval settings.'); @@ -708,7 +796,7 @@ export class EryxAppService extends EventEmitter { autoApprovedToolNames === undefined ? undefined : { autoApprovedToolNames }, ); - const knownToolNames = new Set(await this.listKnownApprovalToolNames(workspace)); + const knownToolNames = new Set(await this.listKnownApprovalToolNames(workspace, project)); const unknownToolName = settings?.autoApprovedToolNames.find((toolName) => !knownToolNames.has(toolName)); if (unknownToolName) { throw new Error(`Unknown approval tool "${unknownToolName}".`); @@ -741,6 +829,15 @@ export class EryxAppService extends EventEmitter { async selectProject(projectId?: string): Promise { const workspace = await this.loadWorkspace(); + if (projectId) { + const project = this.requireProject(workspace, projectId); + const didSyncProjectTooling = await this.syncProjectDiscoveredTooling(workspace, project); + if (didSyncProjectTooling) { + this.pruneUnavailableSessionToolingSelections(workspace); + await this.pruneUnavailableApprovalTools(workspace); + } + } + workspace.selectedProjectId = projectId; workspace.selectedSessionId = workspace.selectedSessionId; return this.persistAndBroadcast(workspace); @@ -1177,18 +1274,29 @@ export class EryxAppService extends EventEmitter { return normalizePatternModels(patternWithApprovalSettings, modelCatalog); } - private async listKnownApprovalToolNames(workspace: WorkspaceState): Promise { + private async listKnownApprovalToolNames( + workspace: WorkspaceState, + project?: ProjectRecord, + ): Promise { const capabilities = await this.loadSidecarCapabilities(); const runtimeTools = capabilities.runtimeTools.length > 0 ? capabilities.runtimeTools : undefined; - return listApprovalToolNames(workspace.settings.tooling, runtimeTools); + const tooling = project + ? resolveProjectToolingSettings(workspace.settings, project.discoveredTooling) + : resolveWorkspaceToolingSettings(workspace.settings); + return listApprovalToolNames(tooling, runtimeTools); } private async pruneUnavailableApprovalTools(workspace: WorkspaceState): Promise { - const knownToolNames = await this.listKnownApprovalToolNames(workspace); + const capabilities = await this.loadSidecarCapabilities(); + const runtimeTools = capabilities.runtimeTools.length > 0 ? capabilities.runtimeTools : undefined; + const workspaceKnownToolNames = listApprovalToolNames( + resolveWorkspaceToolingSettings(workspace.settings), + runtimeTools, + ); let changed = false; for (const pattern of workspace.patterns) { - const nextPolicy = pruneApprovalPolicyTools(pattern.approvalPolicy, knownToolNames); + const nextPolicy = pruneApprovalPolicyTools(pattern.approvalPolicy, workspaceKnownToolNames); if (!equalStringArrays( pattern.approvalPolicy?.autoApprovedToolNames, nextPolicy?.autoApprovedToolNames, @@ -1199,6 +1307,11 @@ export class EryxAppService extends EventEmitter { } for (const session of workspace.sessions) { + const project = this.requireProject(workspace, session.projectId); + const knownToolNames = listApprovalToolNames( + resolveProjectToolingSettings(workspace.settings, project.discoveredTooling), + runtimeTools, + ); const nextSettings = pruneSessionApprovalSettings( session.approvalSettings, knownToolNames, @@ -1219,9 +1332,89 @@ export class EryxAppService extends EventEmitter { workspace: WorkspaceState, session: SessionRecord, ): RunTurnToolingConfig | undefined { + const project = this.requireProject(workspace, session.projectId); + const tooling = resolveProjectToolingSettings(workspace.settings, project.discoveredTooling); const selection = resolveSessionToolingSelection(session); - validateSessionToolingSelectionIds(workspace.settings.tooling, selection); - return buildSessionToolingConfig(workspace.settings.tooling, selection); + validateSessionToolingSelectionIds(tooling, selection); + return buildSessionToolingConfig(tooling, selection); + } + + private async syncUserDiscoveredTooling(workspace: WorkspaceState): Promise { + const nextState = await this.configScanner.scanUser(workspace.settings.discoveredUserTooling); + if (this.equalDiscoveredToolingState(workspace.settings.discoveredUserTooling, nextState)) { + return false; + } + + workspace.settings.discoveredUserTooling = nextState; + return true; + } + + private async syncProjectDiscoveredTooling( + workspace: WorkspaceState, + project: ProjectRecord, + ): Promise { + if (isScratchpadProject(project)) { + if (!project.discoveredTooling || this.equalDiscoveredToolingState(project.discoveredTooling, undefined)) { + return false; + } + + project.discoveredTooling = undefined; + return true; + } + + const nextState = await this.configScanner.scanProject( + project.id, + project.path, + project.discoveredTooling, + ); + if (this.equalDiscoveredToolingState(project.discoveredTooling, nextState)) { + return false; + } + + project.discoveredTooling = nextState; + return true; + } + + private pruneUnavailableSessionToolingSelections(workspace: WorkspaceState): boolean { + let changed = false; + + for (const session of workspace.sessions) { + const project = this.requireProject(workspace, session.projectId); + const effectiveTooling = resolveProjectToolingSettings(workspace.settings, project.discoveredTooling); + const knownMcpServerIds = new Set(effectiveTooling.mcpServers.map((server) => server.id)); + const knownLspProfileIds = new Set(effectiveTooling.lspProfiles.map((profile) => profile.id)); + const selection = resolveSessionToolingSelection(session); + const nextSelection = normalizeSessionToolingSelection({ + enabledMcpServerIds: selection.enabledMcpServerIds.filter((id) => knownMcpServerIds.has(id)), + enabledLspProfileIds: selection.enabledLspProfileIds.filter((id) => knownLspProfileIds.has(id)), + }); + + if ( + equalStringArrays(selection.enabledMcpServerIds, nextSelection.enabledMcpServerIds) + && equalStringArrays(selection.enabledLspProfileIds, nextSelection.enabledLspProfileIds) + ) { + continue; + } + + session.tooling = nextSelection; + changed = true; + } + + return changed; + } + + private resolveDiscoveredToolingStatus( + resolution: DiscoveredToolingResolution, + ): Exclude { + return resolution === 'accept' ? 'accepted' : 'dismissed'; + } + + private equalDiscoveredToolingState( + left?: DiscoveredToolingState, + right?: DiscoveredToolingState, + ): boolean { + return JSON.stringify(normalizeDiscoveredToolingState(left).mcpServers) + === JSON.stringify(normalizeDiscoveredToolingState(right).mcpServers); } private updateSessionRun( @@ -1300,10 +1493,43 @@ export class EryxAppService extends EventEmitter { } private async loadSidecarCapabilities(forceRefresh = false): Promise { - if (forceRefresh || !this.sidecarCapabilities) { - this.sidecarCapabilities = await this.sidecar.describeCapabilities(); + if (forceRefresh) { + this.sidecarCapabilities = undefined; + this.sidecarCapabilitiesPromise = undefined; } - return this.sidecarCapabilities; + if (this.sidecarCapabilities) { + return this.sidecarCapabilities; + } + + if (!this.sidecarCapabilitiesPromise) { + let request!: Promise; + request = (async () => { + try { + const capabilities = await this.fetchSidecarCapabilities(); + this.sidecarCapabilities = capabilities; + return capabilities; + } finally { + if (this.sidecarCapabilitiesPromise === request) { + this.sidecarCapabilitiesPromise = undefined; + } + } + })(); + this.sidecarCapabilitiesPromise = request; + } + + return this.sidecarCapabilitiesPromise; + } + + private async fetchSidecarCapabilities(): Promise { + try { + return await this.sidecar.describeCapabilities(); + } catch (error) { + if (!isSidecarStoppedBeforeCompletionError(error)) { + throw error; + } + } + + return this.sidecar.describeCapabilities(); } } diff --git a/src/main/ipc/registerIpcHandlers.ts b/src/main/ipc/registerIpcHandlers.ts index c80769e..334ceb2 100644 --- a/src/main/ipc/registerIpcHandlers.ts +++ b/src/main/ipc/registerIpcHandlers.ts @@ -3,8 +3,11 @@ import { BrowserWindow, ipcMain } from 'electron'; import { ipcChannels } from '@shared/contracts/channels'; import type { CreateSessionInput, + ResolveProjectDiscoveredToolingInput, + ResolveWorkspaceDiscoveredToolingInput, DuplicateSessionInput, RenameSessionInput, + RescanProjectConfigsInput, ResolveSessionApprovalInput, SaveLspProfileInput, SaveMcpServerInput, @@ -29,9 +32,22 @@ export function registerIpcHandlers(window: BrowserWindow, service: EryxAppServi ipcMain.handle(ipcChannels.loadWorkspace, () => service.loadWorkspace()); ipcMain.handle(ipcChannels.addProject, () => service.addProject()); ipcMain.handle(ipcChannels.removeProject, (_event, projectId: string) => service.removeProject(projectId)); + ipcMain.handle( + ipcChannels.resolveWorkspaceDiscoveredTooling, + (_event, input: ResolveWorkspaceDiscoveredToolingInput) => + service.resolveWorkspaceDiscoveredTooling(input.serverIds, input.resolution), + ); ipcMain.handle(ipcChannels.refreshProjectGitContext, (_event, projectId?: string) => service.refreshProjectGitContext(projectId), ); + ipcMain.handle(ipcChannels.rescanProjectConfigs, (_event, input: RescanProjectConfigsInput) => + service.rescanProjectConfigs(input.projectId), + ); + ipcMain.handle( + ipcChannels.resolveProjectDiscoveredTooling, + (_event, input: ResolveProjectDiscoveredToolingInput) => + service.resolveProjectDiscoveredTooling(input.projectId, input.serverIds, input.resolution), + ); ipcMain.handle(ipcChannels.savePattern, (_event, input: SavePatternInput) => service.savePattern(input.pattern)); ipcMain.handle(ipcChannels.deletePattern, (_event, patternId: string) => service.deletePattern(patternId)); ipcMain.handle(ipcChannels.setPatternFavorite, (_event, input: SetPatternFavoriteInput) => diff --git a/src/main/persistence/workspaceRepository.ts b/src/main/persistence/workspaceRepository.ts index 494d850..e8081df 100644 --- a/src/main/persistence/workspaceRepository.ts +++ b/src/main/persistence/workspaceRepository.ts @@ -3,6 +3,7 @@ import { mkdir } from 'node:fs/promises'; import { createBuiltinPatterns, resolvePatternGraph } from '@shared/domain/pattern'; import type { PatternDefinition } from '@shared/domain/pattern'; import { mergeScratchpadProject } from '@shared/domain/project'; +import { normalizeDiscoveredToolingState } from '@shared/domain/discoveredTooling'; import { normalizeSessionRunRecords } from '@shared/domain/runTimeline'; import { normalizeSessionToolingSelection, @@ -63,7 +64,13 @@ export class WorkspaceRepository { return seeded; } - const projects = mergeScratchpadProject(stored.projects ?? [], this.scratchpadPath); + const projects = mergeScratchpadProject( + (stored.projects ?? []).map((project) => ({ + ...project, + discoveredTooling: normalizeDiscoveredToolingState(project.discoveredTooling), + })), + this.scratchpadPath, + ); const settings = normalizeWorkspaceSettings(stored.settings); const workspace: WorkspaceState = { diff --git a/src/main/services/configScanner.ts b/src/main/services/configScanner.ts new file mode 100644 index 0000000..a7f7bd8 --- /dev/null +++ b/src/main/services/configScanner.ts @@ -0,0 +1,406 @@ +import { readFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +import { + buildDiscoveredMcpServerFingerprint, + buildDiscoveredMcpServerId, + mergeDiscoveredToolingState, + normalizeDiscoveredToolingState, + type DiscoveredMcpServer, + type DiscoveredToolingScope, + type DiscoveredToolingState, +} from '@shared/domain/discoveredTooling'; +import { nowIso } from '@shared/utils/ids'; + +export interface ProjectScanContext { + scope: 'project'; + projectId: string; + projectPath: string; +} + +export interface UserScanContext { + scope: 'user'; + homePath: string; +} + +export type ScanContext = ProjectScanContext | UserScanContext; + +type ConfigScannerResult = + | { kind: 'success'; mcpServers: DiscoveredMcpServer[] } + | { kind: 'retain-previous' }; + +export interface ConfigScanner { + readonly id: string; + readonly scope: DiscoveredToolingScope; + scan(context: ScanContext): Promise; +} + +export class ConfigScannerRegistry { + constructor( + private readonly scanners: ReadonlyArray = defaultConfigScanners, + ) {} + + async scanProject( + projectId: string, + projectPath: string, + current?: DiscoveredToolingState, + ): Promise { + return this.scanAll( + { + scope: 'project', + projectId, + projectPath, + }, + current, + ); + } + + async scanUser(current?: DiscoveredToolingState, homePath = homedir()): Promise { + return this.scanAll( + { + scope: 'user', + homePath, + }, + current, + ); + } + + private async scanAll(context: ScanContext, current?: DiscoveredToolingState): Promise { + const previous = normalizeDiscoveredToolingState(current); + const previousByScanner = new Map(); + + for (const server of previous.mcpServers) { + const entries = previousByScanner.get(server.scannerId) ?? []; + entries.push(server); + previousByScanner.set(server.scannerId, entries); + } + + const scannedServers: DiscoveredMcpServer[] = []; + for (const scanner of this.scanners) { + if (scanner.scope !== context.scope) { + continue; + } + + const result = await scanner.scan(context); + if (result.kind === 'retain-previous') { + scannedServers.push(...(previousByScanner.get(scanner.id) ?? [])); + continue; + } + + scannedServers.push(...result.mcpServers); + } + + return mergeDiscoveredToolingState(previous, scannedServers, nowIso()); + } +} + +const defaultConfigScanners: ReadonlyArray = [ + createProjectJsonMcpScanner({ + id: 'vscode-mcp', + resolvePath: (context) => join(context.projectPath, '.vscode', 'mcp.json'), + sourceLabel: '.vscode\\mcp.json', + rootKey: 'servers', + }), + createProjectJsonMcpScanner({ + id: 'claude-code-mcp', + resolvePath: (context) => join(context.projectPath, '.mcp.json'), + sourceLabel: '.mcp.json', + rootKey: 'mcpServers', + }), + createProjectJsonMcpScanner({ + id: 'copilot-project-mcp', + resolvePath: (context) => join(context.projectPath, '.copilot', 'mcp.json'), + sourceLabel: '.copilot\\mcp.json', + rootKey: 'mcpServers', + }), + createUserJsonMcpScanner({ + id: 'copilot-user-mcp', + resolvePath: (context) => join(context.homePath, '.copilot', 'mcp.json'), + sourceLabel: '~\\.copilot\\mcp.json', + rootKey: 'mcpServers', + }), +]; + +function createProjectJsonMcpScanner(options: { + id: string; + resolvePath: (context: ProjectScanContext) => string; + sourceLabel: string; + rootKey: 'servers' | 'mcpServers'; +}): ConfigScanner { + return createJsonMcpScanner('project', options); +} + +function createUserJsonMcpScanner(options: { + id: string; + resolvePath: (context: UserScanContext) => string; + sourceLabel: string; + rootKey: 'servers' | 'mcpServers'; +}): ConfigScanner { + return createJsonMcpScanner('user', options); +} + +function createJsonMcpScanner(scope: DiscoveredToolingScope, options: { + id: string; + resolvePath: (context: TContext) => string; + sourceLabel: string; + rootKey: 'servers' | 'mcpServers'; +}): ConfigScanner { + return { + id: options.id, + scope, + async scan(context): Promise { + if (context.scope !== scope) { + return { kind: 'success', mcpServers: [] }; + } + + const filePath = options.resolvePath(context as TContext); + const fileContents = await readJsonConfigFile(filePath, options.sourceLabel); + if (fileContents.kind !== 'success') { + return fileContents; + } + + const rawServers = extractMcpServerEntries(fileContents.value, options.rootKey); + if (rawServers.kind !== 'success') { + return rawServers; + } + + const mcpServers = Object.entries(rawServers.value) + .flatMap(([serverName, rawServerConfig]) => { + const server = parseDiscoveredMcpServer({ + scannerId: options.id, + sourcePath: filePath, + sourceLabel: options.sourceLabel, + context, + serverName, + rawServerConfig, + }); + return server ? [server] : []; + }); + + return { kind: 'success', mcpServers }; + }, + }; +} + +async function readJsonConfigFile( + filePath: string, + sourceLabel: string, +): Promise<{ kind: 'success'; value: unknown } | { kind: 'retain-previous' }> { + let contents: string; + try { + contents = await readFile(filePath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { kind: 'success', value: undefined }; + } + + console.warn(`[aryx tooling] Failed to read ${sourceLabel}:`, error); + return { kind: 'retain-previous' }; + } + + try { + return { + kind: 'success', + value: JSON.parse(contents) as unknown, + }; + } catch (error) { + console.warn(`[aryx tooling] Failed to parse ${sourceLabel}:`, error); + return { kind: 'retain-previous' }; + } +} + +function extractMcpServerEntries( + value: unknown, + rootKey: 'servers' | 'mcpServers', +): { kind: 'success'; value: Record } | { kind: 'retain-previous' } { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return { + kind: 'success', + value: {}, + }; + } + + const rawServers = (value as Record)[rootKey]; + if (rawServers === undefined) { + return { + kind: 'success', + value: {}, + }; + } + + if (!rawServers || typeof rawServers !== 'object' || Array.isArray(rawServers)) { + console.warn(`[aryx tooling] Expected "${rootKey}" to be an object.`); + return { kind: 'retain-previous' }; + } + + return { + kind: 'success', + value: rawServers as Record, + }; +} + +function parseDiscoveredMcpServer(options: { + scannerId: string; + sourcePath: string; + sourceLabel: string; + context: ScanContext; + serverName: string; + rawServerConfig: unknown; +}): DiscoveredMcpServer | undefined { + const serverName = options.serverName.trim(); + if (!serverName) { + return undefined; + } + + if (!options.rawServerConfig || typeof options.rawServerConfig !== 'object' || Array.isArray(options.rawServerConfig)) { + console.warn(`[aryx tooling] Ignoring invalid MCP server "${serverName}" from ${options.sourceLabel}.`); + return undefined; + } + + const config = substituteScanVariables( + options.rawServerConfig as Record, + options.context, + ) as Record; + const rawType = normalizeOptionalString(config.type); + const command = normalizeOptionalString(config.command); + const url = normalizeOptionalString(config.url); + const tools = normalizeStringArray(config.tools); + const timeoutMs = normalizeOptionalNumber(config.timeoutMs ?? config.timeout); + const scopeKey = options.context.scope === 'project' ? options.context.projectId : 'workspace'; + const id = buildDiscoveredMcpServerId(options.context.scope, scopeKey, options.scannerId, serverName); + + if (rawType === 'http' || rawType === 'sse' || (!rawType && url)) { + if (!url) { + console.warn(`[aryx tooling] Ignoring MCP server "${serverName}" from ${options.sourceLabel}: missing URL.`); + return undefined; + } + + const server: DiscoveredMcpServer = { + id, + name: serverName, + transport: rawType === 'sse' ? 'sse' : 'http', + tools, + timeoutMs, + scope: options.context.scope, + scannerId: options.scannerId, + sourcePath: options.sourcePath, + sourceLabel: options.sourceLabel, + url, + headers: normalizeStringRecord(config.headers), + fingerprint: '', + status: 'pending', + }; + + return { + ...server, + fingerprint: buildDiscoveredMcpServerFingerprint(server), + }; + } + + if (!command) { + console.warn(`[aryx tooling] Ignoring MCP server "${serverName}" from ${options.sourceLabel}: missing command.`); + return undefined; + } + + const server: DiscoveredMcpServer = { + id, + name: serverName, + transport: 'local', + tools, + timeoutMs, + scope: options.context.scope, + scannerId: options.scannerId, + sourcePath: options.sourcePath, + sourceLabel: options.sourceLabel, + command, + args: normalizeStringArray(config.args), + cwd: normalizeOptionalString(config.cwd), + env: normalizeStringRecord(config.env), + fingerprint: '', + status: 'pending', + }; + + return { + ...server, + fingerprint: buildDiscoveredMcpServerFingerprint(server), + }; +} + +function substituteScanVariables(value: unknown, context: ScanContext): unknown { + if (typeof value === 'string') { + return context.scope === 'project' + ? value.replaceAll('${workspaceFolder}', context.projectPath) + : value; + } + + if (Array.isArray(value)) { + return value.map((item) => substituteScanVariables(item, context)); + } + + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record).map(([key, nestedValue]) => [ + key, + substituteScanVariables(nestedValue, context), + ]), + ); + } + + return value; +} + +function normalizeStringArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + + return [...new Set( + value + .map((item) => normalizeOptionalString(item)) + .filter((item): item is string => item !== undefined), + )]; +} + +function normalizeStringRecord(value: unknown): Record | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + + const entries = Object.entries(value as Record) + .map(([key, rawValue]) => { + const normalizedKey = key.trim(); + const normalizedValue = normalizeOptionalString(rawValue); + return [normalizedKey, normalizedValue] as const; + }) + .filter(([key, normalizedValue]) => key.length > 0 && normalizedValue !== undefined) + .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)); + + if (entries.length === 0) { + return undefined; + } + + return Object.fromEntries(entries) as Record; +} + +function normalizeOptionalString(value: unknown): string | undefined { + if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') { + return undefined; + } + + const normalized = `${value}`.trim(); + return normalized ? normalized : undefined; +} + +function normalizeOptionalNumber(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + + if (typeof value === 'string' && value.trim().length > 0) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + + return undefined; +} diff --git a/src/main/sessionToolingConfig.ts b/src/main/sessionToolingConfig.ts index 81cfdc3..bb4fc8f 100644 --- a/src/main/sessionToolingConfig.ts +++ b/src/main/sessionToolingConfig.ts @@ -55,6 +55,7 @@ export function buildRunTurnToolingConfig( command: server.command, args: [...server.args], cwd: server.cwd, + env: server.env ? { ...server.env } : undefined, }, ]; } @@ -63,12 +64,13 @@ export function buildRunTurnToolingConfig( { id: server.id, name: server.name, - transport: server.transport, - tools: [...server.tools], - timeoutMs: server.timeoutMs, - url: server.url, - }, - ]; + transport: server.transport, + tools: [...server.tools], + timeoutMs: server.timeoutMs, + url: server.url, + headers: server.headers ? { ...server.headers } : undefined, + }, + ]; }); const lspProfiles = selection.enabledLspProfileIds.flatMap((id): RunTurnLspProfileConfig[] => { diff --git a/src/preload/index.ts b/src/preload/index.ts index 415a40c..0218961 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -9,7 +9,12 @@ const api: ElectronApi = { loadWorkspace: () => ipcRenderer.invoke(ipcChannels.loadWorkspace), addProject: () => ipcRenderer.invoke(ipcChannels.addProject), removeProject: (projectId) => ipcRenderer.invoke(ipcChannels.removeProject, projectId), + resolveWorkspaceDiscoveredTooling: (input) => + ipcRenderer.invoke(ipcChannels.resolveWorkspaceDiscoveredTooling, input), refreshProjectGitContext: (projectId) => ipcRenderer.invoke(ipcChannels.refreshProjectGitContext, projectId), + rescanProjectConfigs: (input) => ipcRenderer.invoke(ipcChannels.rescanProjectConfigs, input), + resolveProjectDiscoveredTooling: (input) => + ipcRenderer.invoke(ipcChannels.resolveProjectDiscoveredTooling, input), savePattern: (input) => ipcRenderer.invoke(ipcChannels.savePattern, input), deletePattern: (patternId) => ipcRenderer.invoke(ipcChannels.deletePattern, patternId), setPatternFavorite: (input) => ipcRenderer.invoke(ipcChannels.setPatternFavorite, input), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 56873d2..9c0ef4a 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { AppShell } from '@renderer/components/AppShell'; import { ActivityPanel } from '@renderer/components/ActivityPanel'; import { ChatPane } from '@renderer/components/ChatPane'; +import { DiscoveredToolingModal } from '@renderer/components/DiscoveredToolingModal'; import { NewSessionModal } from '@renderer/components/NewSessionModal'; import { SettingsPanel } from '@renderer/components/SettingsPanel'; import { Sidebar } from '@renderer/components/Sidebar'; @@ -22,10 +23,12 @@ import { resolveReasoningEffort, } from '@shared/domain/models'; import { createDefaultToolApprovalPolicy } from '@shared/domain/approval'; +import { listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling'; import { syncPatternGraph, type PatternDefinition } from '@shared/domain/pattern'; import { isScratchpadProject, SCRATCHPAD_PROJECT_ID } from '@shared/domain/project'; import { applyScratchpadSessionConfig } from '@shared/domain/session'; import type { AppearanceTheme, LspProfileDefinition, McpServerDefinition } from '@shared/domain/tooling'; +import { resolveProjectToolingSettings } from '@shared/domain/tooling'; import type { WorkspaceState } from '@shared/domain/workspace'; import { createId, nowIso } from '@shared/utils/ids'; @@ -91,6 +94,7 @@ export default function App() { const [showSettings, setShowSettings] = useState(false); const [newSessionProjectId, setNewSessionProjectId] = useState(); + const [showDiscoveryModal, setShowDiscoveryModal] = useState(false); // Load workspace on mount useEffect(() => { @@ -170,6 +174,28 @@ export default function App() { [workspace?.projects], ); + // Show discovery modal when pending discovered MCPs exist + const selectedProject = useMemo( + () => workspace?.projects.find((p) => p.id === workspace.selectedProjectId), + [workspace?.projects, workspace?.selectedProjectId], + ); + + const effectiveTooling = useMemo( + () => workspace ? resolveProjectToolingSettings(workspace.settings, selectedProject?.discoveredTooling) : undefined, + [workspace?.settings, selectedProject?.discoveredTooling], + ); + + const hasPendingDiscoveries = useMemo(() => { + if (!workspace) return false; + const pendingUser = listPendingDiscoveredMcpServers(workspace.settings.discoveredUserTooling); + const pendingProject = listPendingDiscoveredMcpServers(selectedProject?.discoveredTooling); + return pendingUser.length > 0 || pendingProject.length > 0; + }, [workspace?.settings.discoveredUserTooling, selectedProject?.discoveredTooling]); + + useEffect(() => { + if (hasPendingDiscoveries) setShowDiscoveryModal(true); + }, [hasPendingDiscoveries]); + const jumpToMessage = useCallback((messageId: string) => { const element = document.querySelector(`[data-message-id="${CSS.escape(messageId)}"]`); if (element) { @@ -248,7 +274,7 @@ export default function App() { project={projectForSession} runtimeTools={sidecarCapabilities?.runtimeTools} session={selectedSession} - toolingSettings={workspace.settings.tooling} + toolingSettings={effectiveTooling ?? workspace.settings.tooling} /> ); detailPanel = ( @@ -314,6 +340,16 @@ export default function App() { sidecarCapabilities={sidecarCapabilities} theme={workspace.settings.theme} toolingSettings={workspace.settings.tooling} + discoveredUserTooling={workspace.settings.discoveredUserTooling} + discoveredProjectTooling={selectedProject?.discoveredTooling} + selectedProjectName={selectedProject?.name} + onRescanProjectConfigs={selectedProject ? () => void api.rescanProjectConfigs({ projectId: selectedProject.id }) : undefined} + onResolveUserDiscoveredTooling={(serverIds, resolution) => { + void api.resolveWorkspaceDiscoveredTooling({ serverIds, resolution }); + }} + onResolveProjectDiscoveredTooling={selectedProject ? (serverIds, resolution) => { + void api.resolveProjectDiscoveredTooling({ projectId: selectedProject.id, serverIds, resolution }); + } : undefined} /> ) : null; @@ -372,6 +408,23 @@ export default function App() { projects={workspace.projects} /> )} + + {showDiscoveryModal && ( + setShowDiscoveryModal(false)} + onResolveProjectServers={(serverIds, resolution) => { + if (selectedProject) { + void api.resolveProjectDiscoveredTooling({ projectId: selectedProject.id, serverIds, resolution }); + } + }} + onResolveUserServers={(serverIds, resolution) => { + void api.resolveWorkspaceDiscoveredTooling({ serverIds, resolution }); + }} + projectDiscoveredTooling={selectedProject?.discoveredTooling} + projectName={selectedProject?.name} + userDiscoveredTooling={workspace.settings.discoveredUserTooling} + /> + )} ); } diff --git a/src/renderer/components/DiscoveredToolingModal.tsx b/src/renderer/components/DiscoveredToolingModal.tsx new file mode 100644 index 0000000..0d36137 --- /dev/null +++ b/src/renderer/components/DiscoveredToolingModal.tsx @@ -0,0 +1,267 @@ +import { useCallback, useEffect, useMemo } from 'react'; +import { Check, FileSearch, Server, X, XCircle } from 'lucide-react'; + +import type { DiscoveredMcpServer, DiscoveredToolingState } from '@shared/domain/discoveredTooling'; +import { listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling'; +import type { ProjectDiscoveredTooling } from '@shared/domain/discoveredTooling'; + +/* ── Props ─────────────────────────────────────────────────── */ + +interface DiscoveredToolingModalProps { + userDiscoveredTooling: DiscoveredToolingState; + projectDiscoveredTooling?: ProjectDiscoveredTooling; + projectName?: string; + onResolveUserServers: (serverIds: string[], resolution: 'accept' | 'dismiss') => void; + onResolveProjectServers: (serverIds: string[], resolution: 'accept' | 'dismiss') => void; + onClose: () => void; +} + +/* ── Modal ─────────────────────────────────────────────────── */ + +export function DiscoveredToolingModal({ + userDiscoveredTooling, + projectDiscoveredTooling, + projectName, + onResolveUserServers, + onResolveProjectServers, + onClose, +}: DiscoveredToolingModalProps) { + const pendingUserServers = useMemo( + () => listPendingDiscoveredMcpServers(userDiscoveredTooling), + [userDiscoveredTooling], + ); + const pendingProjectServers = useMemo( + () => listPendingDiscoveredMcpServers(projectDiscoveredTooling), + [projectDiscoveredTooling], + ); + + const totalPending = pendingUserServers.length + pendingProjectServers.length; + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }, + [onClose], + ); + + useEffect(() => { + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [handleKeyDown]); + + // Auto-close when nothing left to review + useEffect(() => { + if (totalPending === 0) onClose(); + }, [totalPending, onClose]); + + if (totalPending === 0) return null; + + const userGroups = groupBySource(pendingUserServers); + const projectGroups = groupBySource(pendingProjectServers); + + function handleAcceptAll() { + if (pendingUserServers.length > 0) { + onResolveUserServers(pendingUserServers.map((s) => s.id), 'accept'); + } + if (pendingProjectServers.length > 0) { + onResolveProjectServers(pendingProjectServers.map((s) => s.id), 'accept'); + } + } + + function handleDismissAll() { + if (pendingUserServers.length > 0) { + onResolveUserServers(pendingUserServers.map((s) => s.id), 'dismiss'); + } + if (pendingProjectServers.length > 0) { + onResolveProjectServers(pendingProjectServers.map((s) => s.id), 'dismiss'); + } + } + + return ( +
+
+ {/* Header */} +
+
+ +

+ MCP servers found in config files +

+
+ +
+ + {/* Body */} +
+

+ The following MCP servers were found in your config files. Accept the ones you want to + use, or dismiss those you don't need. Accepted servers become available for session tooling. +

+ + {pendingUserServers.length > 0 && ( + + )} + + {pendingProjectServers.length > 0 && ( + + )} +
+ + {/* Footer */} +
+ + {totalPending} server{totalPending === 1 ? '' : 's'} pending review + +
+ + +
+
+
+
+ ); +} + +/* ── Discovered group (by scope) ──────────────────────────── */ + +function DiscoveredGroup({ + scopeLabel, + groups, + onResolve, +}: { + scopeLabel: string; + groups: SourceGroup[]; + onResolve: (serverIds: string[], resolution: 'accept' | 'dismiss') => void; +}) { + return ( +
+
+ {scopeLabel} +
+ {groups.map((group) => ( +
+
+ {group.sourceLabel} + · + + {group.servers.length} server{group.servers.length === 1 ? '' : 's'} + +
+
+ {group.servers.map((server) => ( + onResolve([server.id], 'accept')} + onDismiss={() => onResolve([server.id], 'dismiss')} + server={server} + /> + ))} +
+
+ ))} +
+ ); +} + +/* ── Server row ────────────────────────────────────────────── */ + +function ServerRow({ + server, + onAccept, + onDismiss, +}: { + server: DiscoveredMcpServer; + onAccept: () => void; + onDismiss: () => void; +}) { + const detail = + server.transport === 'local' + ? server.command || 'No command' + : server.url || 'No URL'; + + return ( +
+ +
+
+ + {server.name} + + + {server.transport} + +
+

{detail}

+
+
+ + +
+
+ ); +} + +/* ── Helpers ────────────────────────────────────────────────── */ + +interface SourceGroup { + sourceLabel: string; + servers: DiscoveredMcpServer[]; +} + +function groupBySource(servers: DiscoveredMcpServer[]): SourceGroup[] { + const map = new Map(); + for (const server of servers) { + const group = map.get(server.sourceLabel) ?? []; + group.push(server); + map.set(server.sourceLabel, group); + } + + return [...map.entries()] + .map(([sourceLabel, groupServers]) => ({ sourceLabel, servers: groupServers })) + .sort((a, b) => a.sourceLabel.localeCompare(b.sourceLabel)); +} diff --git a/src/renderer/components/SettingsPanel.tsx b/src/renderer/components/SettingsPanel.tsx index 5af7163..06ae308 100644 --- a/src/renderer/components/SettingsPanel.tsx +++ b/src/renderer/components/SettingsPanel.tsx @@ -1,11 +1,13 @@ import { useState, type ReactNode } from 'react'; -import { ChevronLeft, ChevronRight, Code, Cpu, FolderOpen, Palette, Plus, Server, TriangleAlert, Workflow, Wrench } from 'lucide-react'; +import { ChevronLeft, ChevronRight, Code, Cpu, FolderOpen, Palette, Plus, RefreshCw, Server, TriangleAlert, Workflow, Wrench } from 'lucide-react'; import { CopilotStatusCard } from '@renderer/components/CopilotStatusCard'; import { PatternEditor } from '@renderer/components/PatternEditor'; import { LspProfileEditor } from '@renderer/components/settings/LspProfileEditor'; import { McpServerEditor } from '@renderer/components/settings/McpServerEditor'; import type { SidecarCapabilities } from '@shared/contracts/sidecar'; +import type { DiscoveredMcpServer, DiscoveredToolingState, ProjectDiscoveredTooling } from '@shared/domain/discoveredTooling'; +import { listAcceptedDiscoveredMcpServers, listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling'; import type { ModelDefinition } from '@shared/domain/models'; import type { PatternDefinition } from '@shared/domain/pattern'; import { @@ -23,6 +25,9 @@ interface SettingsPanelProps { sidecarCapabilities?: SidecarCapabilities; theme: AppearanceTheme; toolingSettings: WorkspaceToolingSettings; + discoveredUserTooling: DiscoveredToolingState; + discoveredProjectTooling?: ProjectDiscoveredTooling; + selectedProjectName?: string; isRefreshingCapabilities: boolean; onRefreshCapabilities: () => void; onClose: () => void; @@ -38,6 +43,9 @@ interface SettingsPanelProps { onSetTheme: (theme: AppearanceTheme) => void; onOpenAppDataFolder: () => void; onResetLocalWorkspace: () => Promise; + onRescanProjectConfigs?: () => void; + onResolveUserDiscoveredTooling?: (serverIds: string[], resolution: 'accept' | 'dismiss') => void; + onResolveProjectDiscoveredTooling?: (serverIds: string[], resolution: 'accept' | 'dismiss') => void; } type SettingsSection = 'appearance' | 'connection' | 'patterns' | 'mcp-servers' | 'lsp-profiles' | 'troubleshooting'; @@ -98,6 +106,9 @@ export function SettingsPanel({ sidecarCapabilities, theme, toolingSettings, + discoveredUserTooling, + discoveredProjectTooling, + selectedProjectName, isRefreshingCapabilities, onRefreshCapabilities, onClose, @@ -113,6 +124,9 @@ export function SettingsPanel({ onSetTheme, onOpenAppDataFolder, onResetLocalWorkspace, + onRescanProjectConfigs, + onResolveUserDiscoveredTooling, + onResolveProjectDiscoveredTooling, }: SettingsPanelProps) { const [activeSection, setActiveSection] = useState('appearance'); const [editingPattern, setEditingPattern] = useState(null); @@ -271,6 +285,16 @@ export function SettingsPanel({ servers={toolingSettings.mcpServers} /> )} + {activeSection === 'mcp-servers' && ( + + )} {activeSection === 'lsp-profiles' && ( setEditingLspProfile(structuredClone(profile))} @@ -579,6 +603,181 @@ function EmptyState({ children }: { children: ReactNode }) { ); } +/* ── Discovered MCP section ────────────────────────────────── */ + +function DiscoveredMcpSection({ + discoveredUserTooling, + discoveredProjectTooling, + selectedProjectName, + onRescanProjectConfigs, + onResolveUserDiscoveredTooling, + onResolveProjectDiscoveredTooling, +}: { + discoveredUserTooling: DiscoveredToolingState; + discoveredProjectTooling?: ProjectDiscoveredTooling; + selectedProjectName?: string; + onRescanProjectConfigs?: () => void; + onResolveUserDiscoveredTooling?: (serverIds: string[], resolution: 'accept' | 'dismiss') => void; + onResolveProjectDiscoveredTooling?: (serverIds: string[], resolution: 'accept' | 'dismiss') => void; +}) { + const acceptedUser = listAcceptedDiscoveredMcpServers(discoveredUserTooling); + const pendingUser = listPendingDiscoveredMcpServers(discoveredUserTooling); + const acceptedProject = listAcceptedDiscoveredMcpServers(discoveredProjectTooling); + const pendingProject = listPendingDiscoveredMcpServers(discoveredProjectTooling); + + const hasAny = acceptedUser.length + pendingUser.length + acceptedProject.length + pendingProject.length > 0; + + if (!hasAny) return null; + + return ( +
+ + {onRescanProjectConfigs && ( + + )} + + + {/* User-level discovered */} + {(acceptedUser.length > 0 || pendingUser.length > 0) && ( + + )} + + {/* Project-level discovered */} + {(acceptedProject.length > 0 || pendingProject.length > 0) && ( + + )} +
+ ); +} + +function DiscoveredSubSection({ + label, + description, + accepted, + pending, + onResolve, +}: { + label: string; + description: string; + accepted: DiscoveredMcpServer[]; + pending: DiscoveredMcpServer[]; + onResolve?: (serverIds: string[], resolution: 'accept' | 'dismiss') => void; +}) { + return ( +
+
+
+ {label} +

{description}

+
+
+
+ {accepted.map((server) => ( + onResolve([server.id], 'dismiss') : undefined} + server={server} + status="accepted" + /> + ))} + {pending.map((server) => ( + onResolve([server.id], 'accept') : undefined} + onDismiss={onResolve ? () => onResolve([server.id], 'dismiss') : undefined} + server={server} + status="pending" + /> + ))} +
+
+ ); +} + +function DiscoveredServerRow({ + server, + status, + onAccept, + onDismiss, +}: { + server: DiscoveredMcpServer; + status: 'accepted' | 'pending'; + onAccept?: () => void; + onDismiss?: () => void; +}) { + const detail = + server.transport === 'local' + ? server.command || 'No command' + : server.url || 'No URL'; + + const statusBadge = status === 'accepted' + ? 'bg-emerald-500/10 text-emerald-400' + : 'bg-amber-500/10 text-amber-400'; + + return ( +
+
+
+ {server.name} + + {server.transport} + + + {status} + +
+

+ {detail} + · {server.sourceLabel} +

+
+
+ {onAccept && ( + + )} + {onDismiss && ( + + )} +
+
+ ); +} + function TroubleshootingSection({ onOpenAppDataFolder, onResetLocalWorkspace, diff --git a/src/renderer/components/Sidebar.tsx b/src/renderer/components/Sidebar.tsx index e8c98ab..354a212 100644 --- a/src/renderer/components/Sidebar.tsx +++ b/src/renderer/components/Sidebar.tsx @@ -28,6 +28,7 @@ import { import type { OrchestrationMode, PatternDefinition } from '@shared/domain/pattern'; import { isScratchpadProject, type ProjectRecord, type ProjectGitContext } from '@shared/domain/project'; +import { listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling'; import type { SessionRecord } from '@shared/domain/session'; import { querySessions } from '@shared/domain/sessionLibrary'; import type { WorkspaceState } from '@shared/domain/workspace'; @@ -360,6 +361,10 @@ function ProjectGroup({ ); const runningCount = visibleSessions.filter((s) => s.status === 'running').length; + const pendingDiscoveryCount = useMemo( + () => isScratchpad ? 0 : listPendingDiscoveredMcpServers(project.discoveredTooling).length, + [isScratchpad, project.discoveredTooling], + ); return (
@@ -404,6 +409,14 @@ function ProjectGroup({ {runningCount} )} + {pendingDiscoveryCount > 0 && ( + + {pendingDiscoveryCount} new + + )} {visibleSessions.length} diff --git a/src/renderer/components/chat/InlinePills.tsx b/src/renderer/components/chat/InlinePills.tsx index ef2a0c3..4798fa7 100644 --- a/src/renderer/components/chat/InlinePills.tsx +++ b/src/renderer/components/chat/InlinePills.tsx @@ -225,6 +225,10 @@ export function InlineToolsPill({ const enabledCount = selection.enabledMcpServerIds.length + selection.enabledLspProfileIds.length; const totalCount = mcpServers.length + lspProfiles.length; + const workspaceMcpServers = mcpServers.filter((s) => !s.id.startsWith('discovered_')); + const discoveredUserMcpServers = mcpServers.filter((s) => s.id.startsWith('discovered_user_')); + const discoveredProjectMcpServers = mcpServers.filter((s) => s.id.startsWith('discovered_project_')); + return (