feat: auto-discover MCP servers from project and user config files

Scan .vscode/mcp.json, .mcp.json, .copilot/mcp.json (project-level) and
~/.copilot/mcp.json (user-level) for MCP server definitions. Discovered
servers require explicit user acceptance before activation.

Backend:
- Add ConfigScannerRegistry with per-format scanners and  substitution
- Add discovered tooling domain model with fingerprint-based change detection
- Integrate scanning into workspace load, project add, and project selection
- Merge accepted discovered MCPs into effective runtime tooling
- Extend sidecar contracts with env/headers for real-world MCP configs
- Add IPC channels for accept/dismiss/rescan operations

Frontend:
- Add DiscoveredToolingModal for reviewing pending MCP servers
- Auto-show modal when pending discoveries exist on load or project switch
- Add amber badge on sidebar project headers for pending discoveries
- Add discovered MCP section in Settings panel with accept/dismiss/rescan
- Group InlinePills tool dropdown by Workspace MCP / User MCP / Project MCP
- Pass effective project tooling (including accepted discoveries) to ChatPane

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-25 21:54:06 +01:00
co-authored by Copilot
parent 08a1132d86
commit e5878ba9e8
27 changed files with 2466 additions and 45 deletions
@@ -187,8 +187,10 @@ public sealed class RunTurnMcpServerConfigDto
public int? TimeoutMs { get; init; }
public string? Command { get; init; }
public IReadOnlyList<string>? Args { get; init; }
public IReadOnlyDictionary<string, string>? Env { get; init; }
public string? Cwd { get; init; }
public string? Url { get; init; }
public IReadOnlyDictionary<string, string>? Headers { get; init; }
}
public sealed class RunTurnLspProfileConfigDto
@@ -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<string, string>(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<string, string>(server.Headers, StringComparer.Ordinal),
Tools = ResolveTools(server),
};
}
@@ -18,6 +18,10 @@ public sealed class SessionToolingBundleTests
Transport = "local",
Command = "node",
Args = ["server.js", "--stdio"],
Env = new Dictionary<string, string>
{
["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<string, string>
{
["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<string, string> 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<McpRemoteServerConfig>(configurations["Remote MCP"]);
Assert.Equal("http", remoteConfig.Type);
Assert.Equal("https://example.com/mcp", remoteConfig.Url);
KeyValuePair<string, string> remoteHeader = Assert.Single(remoteConfig.Headers!);
Assert.Equal("Authorization", remoteHeader.Key);
Assert.Equal("Bearer token", remoteHeader.Value);
Assert.Equal(["*"], remoteConfig.Tools);
}
+241 -15
View File
@@ -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<void>;
};
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<AppServiceEvents> {
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<string, PendingApprovalHandle>();
private workspace?: WorkspaceState;
private sidecarCapabilities?: SidecarCapabilities;
private sidecarCapabilitiesPromise?: Promise<SidecarCapabilities>;
private didScheduleInitialProjectGitRefresh = false;
async describeSidecarCapabilities(): Promise<SidecarCapabilities> {
@@ -139,8 +159,23 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
async loadWorkspace(): Promise<WorkspaceState> {
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<AppServiceEvents> {
this.workspace = undefined;
this.sidecarCapabilities = undefined;
this.sidecarCapabilitiesPromise = undefined;
this.didScheduleInitialProjectGitRefresh = false;
return this.loadWorkspace();
@@ -197,6 +233,11 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
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<AppServiceEvents> {
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<AppServiceEvents> {
return this.persistAndBroadcast(workspace);
}
async resolveWorkspaceDiscoveredTooling(
serverIds: string[],
resolution: DiscoveredToolingResolution,
): Promise<WorkspaceState> {
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<WorkspaceState> {
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<WorkspaceState> {
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<WorkspaceState> {
const workspace = await this.loadWorkspace();
const knownApprovalToolNames = await this.listKnownApprovalToolNames(workspace);
@@ -675,7 +760,7 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
): Promise<WorkspaceState> {
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<AppServiceEvents> {
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<AppServiceEvents> {
): Promise<WorkspaceState> {
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<AppServiceEvents> {
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<AppServiceEvents> {
async selectProject(projectId?: string): Promise<WorkspaceState> {
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<AppServiceEvents> {
return normalizePatternModels(patternWithApprovalSettings, modelCatalog);
}
private async listKnownApprovalToolNames(workspace: WorkspaceState): Promise<string[]> {
private async listKnownApprovalToolNames(
workspace: WorkspaceState,
project?: ProjectRecord,
): Promise<string[]> {
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<boolean> {
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<AppServiceEvents> {
}
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<AppServiceEvents> {
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<boolean> {
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<boolean> {
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<DiscoveredToolingStatus, 'pending'> {
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<AppServiceEvents> {
}
private async loadSidecarCapabilities(forceRefresh = false): Promise<SidecarCapabilities> {
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<SidecarCapabilities>;
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<SidecarCapabilities> {
try {
return await this.sidecar.describeCapabilities();
} catch (error) {
if (!isSidecarStoppedBeforeCompletionError(error)) {
throw error;
}
}
return this.sidecar.describeCapabilities();
}
}
+16
View File
@@ -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) =>
+8 -1
View File
@@ -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 = {
+406
View File
@@ -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<ConfigScannerResult>;
}
export class ConfigScannerRegistry {
constructor(
private readonly scanners: ReadonlyArray<ConfigScanner> = defaultConfigScanners,
) {}
async scanProject(
projectId: string,
projectPath: string,
current?: DiscoveredToolingState,
): Promise<DiscoveredToolingState> {
return this.scanAll(
{
scope: 'project',
projectId,
projectPath,
},
current,
);
}
async scanUser(current?: DiscoveredToolingState, homePath = homedir()): Promise<DiscoveredToolingState> {
return this.scanAll(
{
scope: 'user',
homePath,
},
current,
);
}
private async scanAll(context: ScanContext, current?: DiscoveredToolingState): Promise<DiscoveredToolingState> {
const previous = normalizeDiscoveredToolingState(current);
const previousByScanner = new Map<string, DiscoveredMcpServer[]>();
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<ConfigScanner> = [
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<TContext extends ScanContext>(scope: DiscoveredToolingScope, options: {
id: string;
resolvePath: (context: TContext) => string;
sourceLabel: string;
rootKey: 'servers' | 'mcpServers';
}): ConfigScanner {
return {
id: options.id,
scope,
async scan(context): Promise<ConfigScannerResult> {
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<string, unknown> } | { kind: 'retain-previous' } {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {
kind: 'success',
value: {},
};
}
const rawServers = (value as Record<string, unknown>)[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<string, unknown>,
};
}
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<string, unknown>,
options.context,
) as Record<string, unknown>;
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<string, unknown>).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<string, string> | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return undefined;
}
const entries = Object.entries(value as Record<string, unknown>)
.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<string, string>;
}
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;
}
+8 -6
View File
@@ -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[] => {
+5
View File
@@ -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),
+54 -1
View File
@@ -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<string>();
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 && (
<DiscoveredToolingModal
onClose={() => 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}
/>
)}
</>
);
}
@@ -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 (
<div
aria-labelledby="discovered-tooling-title"
aria-modal="true"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
role="dialog"
>
<div className="flex max-h-[80vh] w-full max-w-lg flex-col rounded-xl border border-zinc-800 bg-zinc-900 shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between border-b border-zinc-800 px-5 py-4">
<div className="flex items-center gap-2.5">
<FileSearch className="size-4 text-indigo-400" />
<h2 id="discovered-tooling-title" className="text-[13px] font-semibold text-zinc-100">
MCP servers found in config files
</h2>
</div>
<button
className="flex size-7 items-center justify-center rounded-lg text-zinc-500 transition hover:bg-zinc-800 hover:text-zinc-300"
onClick={onClose}
type="button"
>
<X className="size-4" />
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-5 py-4">
<p className="mb-4 text-[12px] leading-relaxed text-zinc-500">
The following MCP servers were found in your config files. Accept the ones you want to
use, or dismiss those you don&apos;t need. Accepted servers become available for session tooling.
</p>
{pendingUserServers.length > 0 && (
<DiscoveredGroup
groups={userGroups}
onResolve={onResolveUserServers}
scopeLabel="User-level"
/>
)}
{pendingProjectServers.length > 0 && (
<DiscoveredGroup
groups={projectGroups}
onResolve={onResolveProjectServers}
scopeLabel={projectName ? `Project: ${projectName}` : 'Project-level'}
/>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between border-t border-zinc-800 px-5 py-3">
<span className="text-[12px] text-zinc-600">
{totalPending} server{totalPending === 1 ? '' : 's'} pending review
</span>
<div className="flex items-center gap-2">
<button
className="rounded-lg px-3 py-1.5 text-[13px] text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-200"
onClick={handleDismissAll}
type="button"
>
Dismiss All
</button>
<button
className="rounded-lg bg-indigo-600 px-3 py-1.5 text-[13px] font-medium text-white transition hover:bg-indigo-500"
onClick={handleAcceptAll}
type="button"
>
Accept All
</button>
</div>
</div>
</div>
</div>
);
}
/* ── Discovered group (by scope) ──────────────────────────── */
function DiscoveredGroup({
scopeLabel,
groups,
onResolve,
}: {
scopeLabel: string;
groups: SourceGroup[];
onResolve: (serverIds: string[], resolution: 'accept' | 'dismiss') => void;
}) {
return (
<div className="mb-4">
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-zinc-600">
{scopeLabel}
</div>
{groups.map((group) => (
<div className="mb-3" key={group.sourceLabel}>
<div className="mb-1.5 flex items-center gap-1.5 text-[11px] text-zinc-500">
<span className="truncate font-medium">{group.sourceLabel}</span>
<span className="text-zinc-700">·</span>
<span className="text-zinc-600">
{group.servers.length} server{group.servers.length === 1 ? '' : 's'}
</span>
</div>
<div className="space-y-1">
{group.servers.map((server) => (
<ServerRow
key={server.id}
onAccept={() => onResolve([server.id], 'accept')}
onDismiss={() => onResolve([server.id], 'dismiss')}
server={server}
/>
))}
</div>
</div>
))}
</div>
);
}
/* ── 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 (
<div className="group flex items-center gap-3 rounded-lg border border-zinc-800/60 bg-zinc-800/20 px-3 py-2.5">
<Server className="size-3.5 shrink-0 text-zinc-600" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-[13px] font-medium text-zinc-200">
{server.name}
</span>
<span className="rounded-full bg-zinc-800 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-zinc-500">
{server.transport}
</span>
</div>
<p className="mt-0.5 truncate text-[12px] text-zinc-500">{detail}</p>
</div>
<div className="flex items-center gap-1">
<button
className="flex size-7 items-center justify-center rounded-md text-zinc-600 transition hover:bg-red-500/10 hover:text-red-400"
onClick={onDismiss}
title="Dismiss"
type="button"
>
<XCircle className="size-3.5" />
</button>
<button
className="flex size-7 items-center justify-center rounded-md text-zinc-600 transition hover:bg-emerald-500/10 hover:text-emerald-400"
onClick={onAccept}
title="Accept"
type="button"
>
<Check className="size-3.5" />
</button>
</div>
</div>
);
}
/* ── Helpers ────────────────────────────────────────────────── */
interface SourceGroup {
sourceLabel: string;
servers: DiscoveredMcpServer[];
}
function groupBySource(servers: DiscoveredMcpServer[]): SourceGroup[] {
const map = new Map<string, DiscoveredMcpServer[]>();
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));
}
+200 -1
View File
@@ -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<void>;
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<SettingsSection>('appearance');
const [editingPattern, setEditingPattern] = useState<PatternDefinition | null>(null);
@@ -271,6 +285,16 @@ export function SettingsPanel({
servers={toolingSettings.mcpServers}
/>
)}
{activeSection === 'mcp-servers' && (
<DiscoveredMcpSection
discoveredProjectTooling={discoveredProjectTooling}
discoveredUserTooling={discoveredUserTooling}
onRescanProjectConfigs={onRescanProjectConfigs}
onResolveProjectDiscoveredTooling={onResolveProjectDiscoveredTooling}
onResolveUserDiscoveredTooling={onResolveUserDiscoveredTooling}
selectedProjectName={selectedProjectName}
/>
)}
{activeSection === 'lsp-profiles' && (
<LspProfilesSection
onEditProfile={(profile) => 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 (
<div className="mt-8">
<SectionHeader
description="MCP servers discovered from project and user config files. Accepted servers are available for session tooling."
title="Discovered MCP Servers"
>
{onRescanProjectConfigs && (
<button
className="flex items-center gap-1.5 rounded-lg bg-zinc-800 px-3 py-1.5 text-[13px] font-medium text-zinc-200 transition hover:bg-zinc-700"
onClick={onRescanProjectConfigs}
title="Re-scan project config files"
type="button"
>
<RefreshCw className="size-3.5" />
Re-scan
</button>
)}
</SectionHeader>
{/* User-level discovered */}
{(acceptedUser.length > 0 || pendingUser.length > 0) && (
<DiscoveredSubSection
label="User-level"
description="From ~/.copilot/mcp.json"
accepted={acceptedUser}
pending={pendingUser}
onResolve={onResolveUserDiscoveredTooling}
/>
)}
{/* Project-level discovered */}
{(acceptedProject.length > 0 || pendingProject.length > 0) && (
<DiscoveredSubSection
label={selectedProjectName ? `Project: ${selectedProjectName}` : 'Project-level'}
description="From .vscode/mcp.json, .mcp.json, or .copilot/mcp.json"
accepted={acceptedProject}
pending={pendingProject}
onResolve={onResolveProjectDiscoveredTooling}
/>
)}
</div>
);
}
function DiscoveredSubSection({
label,
description,
accepted,
pending,
onResolve,
}: {
label: string;
description: string;
accepted: DiscoveredMcpServer[];
pending: DiscoveredMcpServer[];
onResolve?: (serverIds: string[], resolution: 'accept' | 'dismiss') => void;
}) {
return (
<div className="mb-4">
<div className="mb-2 flex items-center justify-between">
<div>
<span className="text-[12px] font-medium text-zinc-300">{label}</span>
<p className="text-[11px] text-zinc-600">{description}</p>
</div>
</div>
<div className="space-y-1">
{accepted.map((server) => (
<DiscoveredServerRow
key={server.id}
onDismiss={onResolve ? () => onResolve([server.id], 'dismiss') : undefined}
server={server}
status="accepted"
/>
))}
{pending.map((server) => (
<DiscoveredServerRow
key={server.id}
onAccept={onResolve ? () => onResolve([server.id], 'accept') : undefined}
onDismiss={onResolve ? () => onResolve([server.id], 'dismiss') : undefined}
server={server}
status="pending"
/>
))}
</div>
</div>
);
}
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 (
<div className="flex items-center gap-3 rounded-xl border border-transparent px-4 py-3 hover:border-zinc-800 hover:bg-zinc-900">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-[13px] font-medium text-zinc-200">{server.name}</span>
<span className="rounded-full bg-zinc-800 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-zinc-400">
{server.transport}
</span>
<span className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${statusBadge}`}>
{status}
</span>
</div>
<p className="mt-0.5 truncate text-[12px] text-zinc-500">
{detail}
<span className="ml-2 text-zinc-700">· {server.sourceLabel}</span>
</p>
</div>
<div className="flex items-center gap-1">
{onAccept && (
<button
className="rounded-lg px-2.5 py-1 text-[12px] font-medium text-emerald-400 transition hover:bg-emerald-500/10"
onClick={onAccept}
type="button"
>
Accept
</button>
)}
{onDismiss && (
<button
className="rounded-lg px-2.5 py-1 text-[12px] font-medium text-zinc-500 transition hover:bg-zinc-800 hover:text-zinc-300"
onClick={onDismiss}
type="button"
>
{status === 'accepted' ? 'Remove' : 'Dismiss'}
</button>
)}
</div>
</div>
);
}
function TroubleshootingSection({
onOpenAppDataFolder,
onResetLocalWorkspace,
+13
View File
@@ -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 (
<div>
@@ -404,6 +409,14 @@ function ProjectGroup({
{runningCount}
</span>
)}
{pendingDiscoveryCount > 0 && (
<span
className="flex items-center gap-1 rounded-full bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium text-amber-400"
title={`${pendingDiscoveryCount} MCP server${pendingDiscoveryCount === 1 ? '' : 's'} discovered`}
>
{pendingDiscoveryCount} new
</span>
)}
<span className="rounded-full bg-zinc-800 px-1.5 py-0.5 text-[10px] font-medium text-zinc-500">
{visibleSessions.length}
</span>
+61 -20
View File
@@ -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 (
<div className="relative" ref={ref}>
<button
@@ -246,26 +250,29 @@ export function InlineToolsPill({
{open && !disabled && (
<div className="absolute bottom-full left-0 z-40 mb-1.5 w-64 overflow-y-auto rounded-lg border border-zinc-700 bg-zinc-900 py-1 shadow-2xl">
{mcpServers.length > 0 && (
<div>
<div className="px-3 pb-1 pt-2 text-[9px] font-semibold uppercase tracking-wider text-zinc-600">
MCP Servers
</div>
{mcpServers.map((server) => (
<PopoverToggleRow
detail={server.transport === 'local' ? server.command : server.url}
enabled={selection.enabledMcpServerIds.includes(server.id)}
key={server.id}
label={server.name}
onToggle={() =>
onToggle({
...selection,
enabledMcpServerIds: toggleInArray(selection.enabledMcpServerIds, server.id),
})
}
/>
))}
</div>
{workspaceMcpServers.length > 0 && (
<McpServerGroup
label="Workspace MCP"
onToggle={onToggle}
selection={selection}
servers={workspaceMcpServers}
/>
)}
{discoveredUserMcpServers.length > 0 && (
<McpServerGroup
label="User MCP"
onToggle={onToggle}
selection={selection}
servers={discoveredUserMcpServers}
/>
)}
{discoveredProjectMcpServers.length > 0 && (
<McpServerGroup
label="Project MCP"
onToggle={onToggle}
selection={selection}
servers={discoveredProjectMcpServers}
/>
)}
{lspProfiles.length > 0 && (
<div>
@@ -294,6 +301,40 @@ export function InlineToolsPill({
);
}
function McpServerGroup({
label,
servers,
selection,
onToggle,
}: {
label: string;
servers: ReadonlyArray<McpServerDefinition>;
selection: SessionToolingSelection;
onToggle: (selection: SessionToolingSelection) => void;
}) {
return (
<div>
<div className="px-3 pb-1 pt-2 text-[9px] font-semibold uppercase tracking-wider text-zinc-600">
{label}
</div>
{servers.map((server) => (
<PopoverToggleRow
detail={server.transport === 'local' ? server.command : server.url}
enabled={selection.enabledMcpServerIds.includes(server.id)}
key={server.id}
label={server.name}
onToggle={() =>
onToggle({
...selection,
enabledMcpServerIds: toggleInArray(selection.enabledMcpServerIds, server.id),
})
}
/>
))}
</div>
);
}
/* ── InlineApprovalPill ────────────────────────────────────── */
const approvalKindOrder: ApprovalToolKind[] = ['builtin', 'mcp', 'lsp', 'mixed'];
+3
View File
@@ -4,7 +4,10 @@ export const ipcChannels = {
loadWorkspace: 'workspace:load',
addProject: 'workspace:add-project',
removeProject: 'workspace:remove-project',
resolveWorkspaceDiscoveredTooling: 'workspace:resolve-discovered-tooling',
refreshProjectGitContext: 'projects:refresh-git-context',
rescanProjectConfigs: 'project:rescan-configs',
resolveProjectDiscoveredTooling: 'project:resolve-discovered-tooling',
savePattern: 'patterns:save',
deletePattern: 'patterns:delete',
setPatternFavorite: 'patterns:set-favorite',
+20
View File
@@ -70,6 +70,23 @@ export interface SaveLspProfileInput {
profile: LspProfileDefinition;
}
export type DiscoveredToolingResolution = 'accept' | 'dismiss';
export interface RescanProjectConfigsInput {
projectId: string;
}
export interface ResolveProjectDiscoveredToolingInput {
projectId: string;
serverIds: string[];
resolution: DiscoveredToolingResolution;
}
export interface ResolveWorkspaceDiscoveredToolingInput {
serverIds: string[];
resolution: DiscoveredToolingResolution;
}
export interface UpdateSessionToolingInput extends SessionToolingSelection {
sessionId: string;
}
@@ -85,7 +102,10 @@ export interface ElectronApi {
loadWorkspace(): Promise<WorkspaceState>;
addProject(): Promise<WorkspaceState>;
removeProject(projectId: string): Promise<WorkspaceState>;
resolveWorkspaceDiscoveredTooling(input: ResolveWorkspaceDiscoveredToolingInput): Promise<WorkspaceState>;
refreshProjectGitContext(projectId?: string): Promise<WorkspaceState>;
rescanProjectConfigs(input: RescanProjectConfigsInput): Promise<WorkspaceState>;
resolveProjectDiscoveredTooling(input: ResolveProjectDiscoveredToolingInput): Promise<WorkspaceState>;
savePattern(input: SavePatternInput): Promise<WorkspaceState>;
deletePattern(patternId: string): Promise<WorkspaceState>;
saveMcpServer(input: SaveMcpServerInput): Promise<WorkspaceState>;
+2
View File
@@ -101,6 +101,7 @@ export interface RunTurnLocalMcpServerConfig {
command: string;
args: string[];
cwd?: string;
env?: Record<string, string>;
}
export interface RunTurnRemoteMcpServerConfig {
@@ -110,6 +111,7 @@ export interface RunTurnRemoteMcpServerConfig {
tools: string[];
timeoutMs?: number;
url: string;
headers?: Record<string, string>;
}
export type RunTurnMcpServerConfig = RunTurnLocalMcpServerConfig | RunTurnRemoteMcpServerConfig;
+288
View File
@@ -0,0 +1,288 @@
export type DiscoveredToolingScope = 'user' | 'project';
export type DiscoveredToolingStatus = 'pending' | 'accepted' | 'dismissed';
export type DiscoveredMcpServerTransport = 'local' | 'http' | 'sse';
export interface BaseDiscoveredMcpServer {
id: string;
name: string;
transport: DiscoveredMcpServerTransport;
tools: string[];
timeoutMs?: number;
scope: DiscoveredToolingScope;
scannerId: string;
sourcePath: string;
sourceLabel: string;
fingerprint: string;
status: DiscoveredToolingStatus;
}
export interface DiscoveredLocalMcpServer extends BaseDiscoveredMcpServer {
transport: 'local';
command: string;
args: string[];
cwd?: string;
env?: Record<string, string>;
}
export interface DiscoveredRemoteMcpServer extends BaseDiscoveredMcpServer {
transport: 'http' | 'sse';
url: string;
headers?: Record<string, string>;
}
export type DiscoveredMcpServer = DiscoveredLocalMcpServer | DiscoveredRemoteMcpServer;
export interface DiscoveredToolingState {
mcpServers: DiscoveredMcpServer[];
lastScannedAt?: string;
}
export type ProjectDiscoveredTooling = DiscoveredToolingState;
type DiscoveredMcpServerFingerprintInput =
| Omit<DiscoveredLocalMcpServer, 'fingerprint' | 'status'>
| Omit<DiscoveredRemoteMcpServer, 'fingerprint' | 'status'>
| DiscoveredLocalMcpServer
| DiscoveredRemoteMcpServer;
const discoveredStatuses: ReadonlySet<DiscoveredToolingStatus> = new Set(['pending', 'accepted', 'dismissed']);
export function createDiscoveredToolingState(): DiscoveredToolingState {
return {
mcpServers: [],
};
}
export function normalizeDiscoveredToolingState(
value?: Partial<DiscoveredToolingState>,
): DiscoveredToolingState {
return {
mcpServers: (value?.mcpServers ?? []).map(normalizeDiscoveredMcpServer).sort(compareDiscoveredMcpServers),
lastScannedAt: normalizeOptionalString(value?.lastScannedAt),
};
}
export function normalizeDiscoveredMcpServer(server: DiscoveredMcpServer): DiscoveredMcpServer {
const normalizedStatus = discoveredStatuses.has(server.status) ? server.status : 'pending';
const normalizedBase = {
...server,
id: server.id.trim(),
name: server.name.trim(),
tools: normalizeStringArray(server.tools),
scope: server.scope === 'user' ? 'user' : 'project',
scannerId: server.scannerId.trim(),
sourcePath: server.sourcePath.trim(),
sourceLabel: server.sourceLabel.trim(),
status: normalizedStatus,
} satisfies BaseDiscoveredMcpServer;
if (server.transport === 'local') {
const normalizedServer: DiscoveredLocalMcpServer = {
...normalizedBase,
transport: 'local',
command: server.command.trim(),
args: normalizeStringArray(server.args),
cwd: normalizeOptionalString(server.cwd),
env: normalizeStringRecord(server.env),
};
return {
...normalizedServer,
fingerprint: normalizeOptionalString(server.fingerprint) ?? buildDiscoveredMcpServerFingerprint(normalizedServer),
};
}
const normalizedServer: DiscoveredRemoteMcpServer = {
...normalizedBase,
transport: server.transport,
url: server.url.trim(),
headers: normalizeStringRecord(server.headers),
};
return {
...normalizedServer,
fingerprint: normalizeOptionalString(server.fingerprint) ?? buildDiscoveredMcpServerFingerprint(normalizedServer),
};
}
export function mergeDiscoveredToolingState(
current: DiscoveredToolingState | undefined,
scannedMcpServers: ReadonlyArray<DiscoveredMcpServer>,
lastScannedAt: string,
): DiscoveredToolingState {
const normalizedCurrent = normalizeDiscoveredToolingState(current);
const currentById = new Map(normalizedCurrent.mcpServers.map((server) => [server.id, server]));
const mergedMcpServers = scannedMcpServers
.map(normalizeDiscoveredMcpServer)
.map((server) => {
const existing = currentById.get(server.id);
if (existing && existing.fingerprint === server.fingerprint) {
return {
...server,
status: existing.status,
} satisfies DiscoveredMcpServer;
}
return {
...server,
status: 'pending',
} satisfies DiscoveredMcpServer;
})
.sort(compareDiscoveredMcpServers);
return {
mcpServers: mergedMcpServers,
lastScannedAt,
};
}
export function applyDiscoveredMcpServerStatus(
state: DiscoveredToolingState | undefined,
serverIds: ReadonlyArray<string>,
status: Exclude<DiscoveredToolingStatus, 'pending'>,
): DiscoveredToolingState {
const normalizedState = normalizeDiscoveredToolingState(state);
const serverIdSet = new Set(normalizeStringArray(serverIds));
return {
...normalizedState,
mcpServers: normalizedState.mcpServers.map((server) =>
serverIdSet.has(server.id)
? {
...server,
status,
}
: server),
};
}
export function listAcceptedDiscoveredMcpServers(
state?: Partial<DiscoveredToolingState>,
): DiscoveredMcpServer[] {
return normalizeDiscoveredToolingState(state).mcpServers.filter((server) => server.status === 'accepted');
}
export function listPendingDiscoveredMcpServers(
state?: Partial<DiscoveredToolingState>,
): DiscoveredMcpServer[] {
return normalizeDiscoveredToolingState(state).mcpServers.filter((server) => server.status === 'pending');
}
export function buildDiscoveredMcpServerId(
scope: DiscoveredToolingScope,
scopeKey: string,
scannerId: string,
serverName: string,
): string {
const normalizedScope = normalizeIdentifierSegment(scope);
const normalizedScopeKey = normalizeIdentifierSegment(scopeKey);
const normalizedScanner = normalizeIdentifierSegment(scannerId);
const normalizedName = normalizeIdentifierSegment(serverName);
return `discovered_${normalizedScope}_${normalizedScopeKey}_${normalizedScanner}_${normalizedName}`;
}
export function buildDiscoveredMcpServerFingerprint(
server: DiscoveredMcpServerFingerprintInput,
): string {
const normalizedBase = {
id: server.id.trim(),
name: server.name.trim(),
transport: server.transport,
tools: normalizeStringArray(server.tools),
timeoutMs: server.timeoutMs,
scope: server.scope === 'user' ? 'user' : 'project',
scannerId: server.scannerId.trim(),
sourcePath: server.sourcePath.trim(),
};
const serialized = server.transport === 'local'
? stableSerialize({
...normalizedBase,
command: server.command.trim(),
args: normalizeStringArray(server.args),
cwd: normalizeOptionalString(server.cwd),
env: normalizeStringRecord(server.env),
})
: stableSerialize({
...normalizedBase,
url: server.url.trim(),
headers: normalizeStringRecord(server.headers),
});
return hashString(serialized);
}
function compareDiscoveredMcpServers(left: DiscoveredMcpServer, right: DiscoveredMcpServer): number {
return (
left.scope.localeCompare(right.scope)
|| left.sourceLabel.localeCompare(right.sourceLabel)
|| left.name.localeCompare(right.name)
|| left.id.localeCompare(right.id)
);
}
function normalizeStringArray(values?: ReadonlyArray<string>): string[] {
if (!values) {
return [];
}
return [...new Set(values.map((value) => value.trim()).filter((value) => value.length > 0))];
}
function normalizeStringRecord(record?: Record<string, string>): Record<string, string> | undefined {
if (!record) {
return undefined;
}
const normalizedEntries = Object.entries(record)
.map(([key, value]) => [key.trim(), value.trim()] as const)
.filter(([key, value]) => key.length > 0 && value.length > 0)
.sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey));
if (normalizedEntries.length === 0) {
return undefined;
}
return Object.fromEntries(normalizedEntries);
}
function normalizeOptionalString(value?: string): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function normalizeIdentifierSegment(value: string): string {
const normalized = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
return normalized || 'default';
}
function stableSerialize(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map(stableSerialize).join(',')}]`;
}
if (value && typeof value === 'object') {
const entries = Object.entries(value as Record<string, unknown>)
.filter(([, nestedValue]) => nestedValue !== undefined)
.sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey));
return `{${entries.map(([key, nestedValue]) => `${JSON.stringify(key)}:${stableSerialize(nestedValue)}`).join(',')}}`;
}
return JSON.stringify(value);
}
function hashString(value: string): string {
let hash = 2166136261;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return `fnv1a_${(hash >>> 0).toString(16).padStart(8, '0')}`;
}
+2
View File
@@ -1,4 +1,5 @@
import { nowIso } from '@shared/utils/ids';
import type { ProjectDiscoveredTooling } from '@shared/domain/discoveredTooling';
export type ProjectGitContextStatus = 'ready' | 'not-repository' | 'git-missing' | 'error';
@@ -37,6 +38,7 @@ export interface ProjectRecord {
path: string;
addedAt: string;
git?: ProjectGitContext;
discoveredTooling?: ProjectDiscoveredTooling;
}
export const SCRATCHPAD_PROJECT_ID = 'project-scratchpad';
+101
View File
@@ -1,3 +1,12 @@
import {
createDiscoveredToolingState,
listAcceptedDiscoveredMcpServers,
normalizeDiscoveredToolingState,
type DiscoveredToolingState,
type ProjectDiscoveredTooling,
} from '@shared/domain/discoveredTooling';
import { nowIso } from '@shared/utils/ids';
export type McpServerTransport = 'local' | 'http' | 'sse';
export interface BaseMcpServerDefinition {
@@ -15,11 +24,13 @@ export interface LocalMcpServerDefinition extends BaseMcpServerDefinition {
command: string;
args: string[];
cwd?: string;
env?: Record<string, string>;
}
export interface RemoteMcpServerDefinition extends BaseMcpServerDefinition {
transport: 'http' | 'sse';
url: string;
headers?: Record<string, string>;
}
export type McpServerDefinition = LocalMcpServerDefinition | RemoteMcpServerDefinition;
@@ -45,6 +56,7 @@ export type AppearanceTheme = 'dark' | 'light' | 'system';
export interface WorkspaceSettings {
theme: AppearanceTheme;
tooling: WorkspaceToolingSettings;
discoveredUserTooling: DiscoveredToolingState;
}
export interface SessionToolingSelection {
@@ -95,6 +107,7 @@ export function createWorkspaceSettings(): WorkspaceSettings {
mcpServers: [],
lspProfiles: [],
},
discoveredUserTooling: createDiscoveredToolingState(),
};
}
@@ -118,9 +131,27 @@ export function normalizeWorkspaceSettings(settings?: Partial<WorkspaceSettings>
mcpServers: (settings?.tooling?.mcpServers ?? []).map(normalizeMcpServerDefinition),
lspProfiles: (settings?.tooling?.lspProfiles ?? []).map(normalizeLspProfileDefinition),
},
discoveredUserTooling: normalizeDiscoveredToolingState(settings?.discoveredUserTooling),
};
}
export function resolveWorkspaceToolingSettings(settings: WorkspaceSettings): WorkspaceToolingSettings {
return mergeAcceptedDiscoveredMcpServers(
{
mcpServers: settings.tooling.mcpServers.map((server) => ({ ...server })),
lspProfiles: settings.tooling.lspProfiles.map((profile) => ({ ...profile })),
},
settings.discoveredUserTooling,
);
}
export function resolveProjectToolingSettings(
settings: WorkspaceSettings,
projectDiscoveredTooling?: ProjectDiscoveredTooling,
): WorkspaceToolingSettings {
return mergeAcceptedDiscoveredMcpServers(resolveWorkspaceToolingSettings(settings), projectDiscoveredTooling);
}
export function normalizeSessionToolingSelection(
selection?: Partial<SessionToolingSelection>,
): SessionToolingSelection {
@@ -249,6 +280,7 @@ export function normalizeMcpServerDefinition(server: McpServerDefinition): McpSe
command: server.command.trim(),
args: normalizeStringArray(server.args),
cwd: server.cwd?.trim() || undefined,
env: normalizeStringRecord(server.env),
};
}
@@ -256,6 +288,7 @@ export function normalizeMcpServerDefinition(server: McpServerDefinition): McpSe
...base,
transport: server.transport,
url: server.url.trim(),
headers: normalizeStringRecord(server.headers),
};
}
@@ -274,6 +307,57 @@ function normalizeFileExtensions(fileExtensions: string[]): string[] {
return normalizeStringArray(fileExtensions).map((value) => (value.startsWith('.') ? value : `.${value}`));
}
function mergeAcceptedDiscoveredMcpServers(
tooling: WorkspaceToolingSettings,
discoveredTooling?: DiscoveredToolingState,
): WorkspaceToolingSettings {
const discoveredMcpServers = listAcceptedDiscoveredMcpServers(discoveredTooling).map((server) =>
toResolvedMcpServerDefinition(server, discoveredTooling?.lastScannedAt),
);
if (discoveredMcpServers.length === 0) {
return tooling;
}
return {
mcpServers: [...tooling.mcpServers, ...discoveredMcpServers],
lspProfiles: [...tooling.lspProfiles],
};
}
function toResolvedMcpServerDefinition(
server: ReturnType<typeof listAcceptedDiscoveredMcpServers>[number],
timestamp = nowIso(),
): McpServerDefinition {
if (server.transport === 'local') {
return normalizeMcpServerDefinition({
id: server.id,
name: server.name,
transport: 'local',
command: server.command,
args: [...server.args],
cwd: server.cwd,
env: server.env ? { ...server.env } : undefined,
tools: [...server.tools],
timeoutMs: server.timeoutMs,
createdAt: timestamp,
updatedAt: timestamp,
});
}
return normalizeMcpServerDefinition({
id: server.id,
name: server.name,
transport: server.transport,
url: server.url,
headers: server.headers ? { ...server.headers } : undefined,
tools: [...server.tools],
timeoutMs: server.timeoutMs,
createdAt: timestamp,
updatedAt: timestamp,
});
}
function requiresTypeScriptLanguageServerStdio(command: string): boolean {
const executableName = command
.trim()
@@ -350,3 +434,20 @@ function normalizeStringArray(values?: ReadonlyArray<string>): string[] {
return [...new Set(values.map((value) => value.trim()).filter((value) => value.length > 0))];
}
function normalizeStringRecord(record?: Record<string, string>): Record<string, string> | undefined {
if (!record) {
return undefined;
}
const normalizedEntries = Object.entries(record)
.map(([key, value]) => [key.trim(), value.trim()] as const)
.filter(([key, value]) => key.length > 0 && value.length > 0)
.sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey));
if (normalizedEntries.length === 0) {
return undefined;
}
return Object.fromEntries(normalizedEntries);
}
@@ -0,0 +1,227 @@
import { describe, expect, mock, test } from 'bun:test';
import type { RunTurnCommand } from '@shared/contracts/sidecar';
import type { PatternDefinition } from '@shared/domain/pattern';
import type { ProjectRecord } from '@shared/domain/project';
import type { SessionRecord } from '@shared/domain/session';
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
const TIMESTAMP = '2026-03-25T00:00:00.000Z';
mock.module('electron', () => ({
app: {
isPackaged: false,
getAppPath: () => 'C:\\workspace\\personal\\repositories\\eryx',
getPath: () => 'C:\\workspace\\personal\\repositories\\eryx\\tests\\fixtures',
},
dialog: {
showOpenDialog: async () => ({ canceled: true, filePaths: [] }),
},
shell: {
openPath: async () => '',
},
}));
mock.module('keytar', () => ({
default: {
getPassword: async () => null,
setPassword: async () => undefined,
deletePassword: async () => false,
},
}));
const { EryxAppService } = await import('@main/EryxAppService');
function createProject(overrides?: Partial<ProjectRecord>): ProjectRecord {
return {
id: 'project-alpha',
name: 'alpha',
path: 'C:\\workspace\\alpha',
addedAt: TIMESTAMP,
...overrides,
};
}
function createSession(projectId: string, patternId: string, overrides?: Partial<SessionRecord>): SessionRecord {
return {
id: 'session-alpha',
projectId,
patternId,
title: 'Alpha session',
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
status: 'idle',
messages: [],
runs: [],
...overrides,
};
}
function createService(
workspace: WorkspaceState,
pattern: PatternDefinition,
options?: {
captureRunTurn?: (command: RunTurnCommand) => void;
},
): InstanceType<typeof EryxAppService> {
const service = new EryxAppService();
const internals = service as unknown as Record<string, unknown>;
internals.loadWorkspace = async () => workspace;
internals.persistAndBroadcast = async (nextWorkspace: WorkspaceState) => nextWorkspace;
internals.buildEffectivePattern = async () => pattern;
internals.awaitFinalResponseApproval = async () => undefined;
internals.finalizeTurn = () => undefined;
internals.emitSessionEvent = () => undefined;
internals.pruneUnavailableApprovalTools = async () => false;
internals.pruneUnavailableSessionToolingSelections = () => false;
(
service as unknown as {
sidecar: {
runTurn: (
command: RunTurnCommand,
onDelta: unknown,
onActivity: unknown,
onApproval: unknown,
) => Promise<[]>;
resolveApproval: () => Promise<void>;
};
}
).sidecar = {
runTurn: async (command) => {
options?.captureRunTurn?.(command);
return [];
},
resolveApproval: async () => undefined,
};
return service;
}
describe('EryxAppService discovered tooling', () => {
test('allows project-discovered MCP servers to be accepted and used in project sessions', async () => {
const workspace = createWorkspaceSeed();
const pattern = workspace.patterns.find((candidate) => candidate.mode === 'single');
if (!pattern) {
throw new Error('Expected a single-agent pattern in the workspace seed.');
}
const project = createProject({
discoveredTooling: {
mcpServers: [
{
id: 'discovered_project_project_alpha_vscode_mcp_git_mcp',
name: 'Git MCP',
transport: 'local',
command: 'node',
args: ['project-mcp.js'],
cwd: 'C:\\workspace\\alpha',
tools: ['git.status'],
scope: 'project',
scannerId: 'vscode-mcp',
sourcePath: 'C:\\workspace\\alpha\\.vscode\\mcp.json',
sourceLabel: '.vscode\\mcp.json',
fingerprint: 'fingerprint-project',
status: 'pending',
},
],
lastScannedAt: TIMESTAMP,
},
});
const session = createSession(project.id, pattern.id);
workspace.projects = [project];
workspace.sessions = [session];
workspace.selectedProjectId = project.id;
workspace.selectedPatternId = pattern.id;
workspace.selectedSessionId = session.id;
let command: RunTurnCommand | undefined;
const service = createService(workspace, pattern, {
captureRunTurn: (capturedCommand) => {
command = capturedCommand;
},
});
await service.resolveProjectDiscoveredTooling(project.id, [project.discoveredTooling!.mcpServers[0]!.id], 'accept');
await service.updateSessionTooling(session.id, [project.discoveredTooling!.mcpServers[0]!.id], []);
await service.sendSessionMessage(session.id, 'Use the project MCP server.');
expect(command?.tooling?.mcpServers).toEqual([
{
id: 'discovered_project_project_alpha_vscode_mcp_git_mcp',
name: 'Git MCP',
transport: 'local',
command: 'node',
args: ['project-mcp.js'],
cwd: 'C:\\workspace\\alpha',
tools: ['git.status'],
},
]);
});
test('allows accepted user-discovered MCP servers to be used across projects', async () => {
const workspace = createWorkspaceSeed();
const pattern = workspace.patterns.find((candidate) => candidate.mode === 'single');
if (!pattern) {
throw new Error('Expected a single-agent pattern in the workspace seed.');
}
workspace.settings.discoveredUserTooling = {
mcpServers: [
{
id: 'discovered_user_workspace_copilot_user_mcp_github',
name: 'GitHub MCP',
transport: 'http',
url: 'https://example.com/mcp',
headers: { Authorization: 'Bearer token' },
tools: ['github.issues'],
scope: 'user',
scannerId: 'copilot-user-mcp',
sourcePath: 'C:\\Users\\tester\\.copilot\\mcp.json',
sourceLabel: '~\\.copilot\\mcp.json',
fingerprint: 'fingerprint-user',
status: 'pending',
},
],
lastScannedAt: TIMESTAMP,
};
const project = createProject();
const session = createSession(project.id, pattern.id);
workspace.projects = [project];
workspace.sessions = [session];
workspace.selectedProjectId = project.id;
workspace.selectedPatternId = pattern.id;
workspace.selectedSessionId = session.id;
let command: RunTurnCommand | undefined;
const service = createService(workspace, pattern, {
captureRunTurn: (capturedCommand) => {
command = capturedCommand;
},
});
await service.resolveWorkspaceDiscoveredTooling(
[workspace.settings.discoveredUserTooling.mcpServers[0]!.id],
'accept',
);
await service.updateSessionTooling(
session.id,
[workspace.settings.discoveredUserTooling.mcpServers[0]!.id],
[],
);
await service.sendSessionMessage(session.id, 'Use the user MCP server.');
expect(command?.tooling?.mcpServers).toEqual([
{
id: 'discovered_user_workspace_copilot_user_mcp_github',
name: 'GitHub MCP',
transport: 'http',
url: 'https://example.com/mcp',
headers: { Authorization: 'Bearer token' },
tools: ['github.issues'],
},
]);
});
});
+184
View File
@@ -0,0 +1,184 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { ConfigScannerRegistry } from '@main/services/configScanner';
const temporaryPaths: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryPaths.splice(0).map((path) => rm(path, { recursive: true, force: true })),
);
});
async function createTempDirectory(): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), 'eryx-config-scanner-'));
temporaryPaths.push(directory);
return directory;
}
describe('ConfigScannerRegistry', () => {
test('scans supported project-level MCP config formats', async () => {
const projectPath = await createTempDirectory();
await mkdir(join(projectPath, '.vscode'), { recursive: true });
await mkdir(join(projectPath, '.copilot'), { recursive: true });
await writeFile(
join(projectPath, '.vscode', 'mcp.json'),
JSON.stringify({
servers: {
filesystem: {
command: 'node',
args: ['${workspaceFolder}\\server.js'],
cwd: '${workspaceFolder}',
env: { DEBUG: 'true' },
tools: ['fs.read'],
},
},
}),
'utf8',
);
await writeFile(
join(projectPath, '.mcp.json'),
JSON.stringify({
mcpServers: {
remote: {
type: 'http',
url: 'https://example.com/mcp',
headers: { Authorization: 'Bearer token' },
tools: ['remote.tool'],
},
},
}),
'utf8',
);
await writeFile(
join(projectPath, '.copilot', 'mcp.json'),
JSON.stringify({
mcpServers: {
local: {
command: 'python',
args: ['tool.py'],
timeout: 1500,
},
},
}),
'utf8',
);
const scanned = await new ConfigScannerRegistry().scanProject('project-alpha', projectPath);
expect(scanned.mcpServers).toHaveLength(3);
expect(scanned.mcpServers).toContainEqual({
id: 'discovered_project_project_alpha_vscode_mcp_filesystem',
name: 'filesystem',
transport: 'local',
command: 'node',
args: [`${projectPath}\\server.js`],
cwd: projectPath,
env: { DEBUG: 'true' },
tools: ['fs.read'],
scope: 'project',
scannerId: 'vscode-mcp',
sourcePath: join(projectPath, '.vscode', 'mcp.json'),
sourceLabel: '.vscode\\mcp.json',
fingerprint: expect.any(String),
status: 'pending',
});
expect(scanned.mcpServers).toContainEqual({
id: 'discovered_project_project_alpha_claude_code_mcp_remote',
name: 'remote',
transport: 'http',
url: 'https://example.com/mcp',
headers: { Authorization: 'Bearer token' },
tools: ['remote.tool'],
scope: 'project',
scannerId: 'claude-code-mcp',
sourcePath: join(projectPath, '.mcp.json'),
sourceLabel: '.mcp.json',
fingerprint: expect.any(String),
status: 'pending',
});
expect(scanned.mcpServers).toContainEqual({
id: 'discovered_project_project_alpha_copilot_project_mcp_local',
name: 'local',
transport: 'local',
command: 'python',
args: ['tool.py'],
tools: [],
timeoutMs: 1500,
scope: 'project',
scannerId: 'copilot-project-mcp',
sourcePath: join(projectPath, '.copilot', 'mcp.json'),
sourceLabel: '.copilot\\mcp.json',
fingerprint: expect.any(String),
status: 'pending',
});
});
test('scans user-level Copilot MCP config files', async () => {
const homePath = await createTempDirectory();
await mkdir(join(homePath, '.copilot'), { recursive: true });
await writeFile(
join(homePath, '.copilot', 'mcp.json'),
JSON.stringify({
mcpServers: {
github: {
command: 'gh',
args: ['aw', 'mcp-server'],
},
},
}),
'utf8',
);
const scanned = await new ConfigScannerRegistry().scanUser(undefined, homePath);
expect(scanned.mcpServers).toEqual([
{
id: 'discovered_user_workspace_copilot_user_mcp_github',
name: 'github',
transport: 'local',
command: 'gh',
args: ['aw', 'mcp-server'],
tools: [],
scope: 'user',
scannerId: 'copilot-user-mcp',
sourcePath: join(homePath, '.copilot', 'mcp.json'),
sourceLabel: '~\\.copilot\\mcp.json',
fingerprint: expect.any(String),
status: 'pending',
},
]);
});
test('retains previous scanner results when a config file becomes malformed', async () => {
const projectPath = await createTempDirectory();
await mkdir(join(projectPath, '.vscode'), { recursive: true });
const filePath = join(projectPath, '.vscode', 'mcp.json');
await writeFile(
filePath,
JSON.stringify({
servers: {
filesystem: {
command: 'node',
args: ['server.js'],
},
},
}),
'utf8',
);
const registry = new ConfigScannerRegistry();
const firstScan = await registry.scanProject('project-alpha', projectPath);
await writeFile(filePath, '{ invalid json', 'utf8');
const secondScan = await registry.scanProject('project-alpha', projectPath, firstScan);
expect(secondScan.mcpServers).toEqual(firstScan.mcpServers);
});
});
+21 -1
View File
@@ -17,10 +17,21 @@ const TOOLING: WorkspaceToolingSettings = {
command: 'node',
args: ['server.js'],
cwd: 'C:\\workspace\\repo',
env: { DEBUG: 'true' },
tools: ['git.status'],
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
},
{
id: 'mcp-remote',
name: 'Remote MCP',
transport: 'http',
url: 'https://example.com/mcp',
headers: { Authorization: 'Bearer token' },
tools: ['remote.tool'],
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
},
],
lspProfiles: [
{
@@ -61,7 +72,7 @@ describe('session tooling config helpers', () => {
test('builds a run-turn tooling config from selected MCP and LSP ids', () => {
expect(
buildRunTurnToolingConfig(TOOLING, {
enabledMcpServerIds: ['mcp-git'],
enabledMcpServerIds: ['mcp-git', 'mcp-remote'],
enabledLspProfileIds: ['lsp-ts'],
}),
).toEqual({
@@ -74,6 +85,15 @@ describe('session tooling config helpers', () => {
command: 'node',
args: ['server.js'],
cwd: 'C:\\workspace\\repo',
env: { DEBUG: 'true' },
},
{
id: 'mcp-remote',
name: 'Remote MCP',
transport: 'http',
tools: ['remote.tool'],
url: 'https://example.com/mcp',
headers: { Authorization: 'Bearer token' },
},
],
lspProfiles: [
+120
View File
@@ -0,0 +1,120 @@
import { describe, expect, test } from 'bun:test';
import type { DiscoveredLocalMcpServer } from '@shared/domain/discoveredTooling';
import { listPendingDiscoveredMcpServers, normalizeDiscoveredToolingState, type DiscoveredToolingState } from '@shared/domain/discoveredTooling';
import { resolveProjectToolingSettings, createWorkspaceSettings, type WorkspaceSettings } from '@shared/domain/tooling';
function makeDiscoveredServer(
overrides: Partial<DiscoveredLocalMcpServer> & { id: string; name: string },
): DiscoveredLocalMcpServer {
return {
transport: 'local',
command: 'test-cmd',
args: [],
tools: [],
scope: 'user',
scannerId: 'test-scanner',
sourcePath: '/test/path',
sourceLabel: 'test config',
fingerprint: 'fnv1a_00000001',
status: 'pending',
...overrides,
};
}
describe('frontend discovered tooling integration', () => {
test('pending discovery detection drives modal visibility', () => {
const emptyState: DiscoveredToolingState = { mcpServers: [], lastScannedAt: undefined };
expect(listPendingDiscoveredMcpServers(emptyState)).toHaveLength(0);
const withPending: DiscoveredToolingState = {
mcpServers: [
makeDiscoveredServer({ id: 'discovered_user_test_scanner_alpha', name: 'alpha', status: 'pending' }),
makeDiscoveredServer({ id: 'discovered_user_test_scanner_beta', name: 'beta', status: 'accepted' }),
],
};
expect(listPendingDiscoveredMcpServers(withPending)).toHaveLength(1);
expect(listPendingDiscoveredMcpServers(withPending)[0].name).toBe('alpha');
});
test('effective project tooling merges accepted discovered MCPs for session UI', () => {
const settings: WorkspaceSettings = {
...createWorkspaceSettings(),
discoveredUserTooling: normalizeDiscoveredToolingState({
mcpServers: [
makeDiscoveredServer({
id: 'discovered_user_ws_scanner_user_mcp',
name: 'user-mcp',
status: 'accepted',
scope: 'user',
}),
],
}),
};
const projectTooling = normalizeDiscoveredToolingState({
mcpServers: [
makeDiscoveredServer({
id: 'discovered_project_proj_scanner_proj_mcp',
name: 'proj-mcp',
status: 'accepted',
scope: 'project',
}),
],
});
const effective = resolveProjectToolingSettings(settings, projectTooling);
const serverIds = effective.mcpServers.map((s) => s.id);
expect(serverIds).toContain('discovered_user_ws_scanner_user_mcp');
expect(serverIds).toContain('discovered_project_proj_scanner_proj_mcp');
});
test('MCP server IDs are correctly categorized by prefix for tool grouping', () => {
const settings: WorkspaceSettings = {
...createWorkspaceSettings(),
tooling: {
mcpServers: [
{ id: 'mcp-manual', name: 'manual', transport: 'local', command: 'cmd', args: [], tools: [], createdAt: '', updatedAt: '' },
],
lspProfiles: [],
},
discoveredUserTooling: normalizeDiscoveredToolingState({
mcpServers: [
makeDiscoveredServer({
id: 'discovered_user_ws_scanner_user_srv',
name: 'user-srv',
status: 'accepted',
scope: 'user',
}),
],
}),
};
const projectTooling = normalizeDiscoveredToolingState({
mcpServers: [
makeDiscoveredServer({
id: 'discovered_project_proj_scanner_proj_srv',
name: 'proj-srv',
status: 'accepted',
scope: 'project',
}),
],
});
const effective = resolveProjectToolingSettings(settings, projectTooling);
const workspaceMcp = effective.mcpServers.filter((s) => !s.id.startsWith('discovered_'));
const userDiscovered = effective.mcpServers.filter((s) => s.id.startsWith('discovered_user_'));
const projectDiscovered = effective.mcpServers.filter((s) => s.id.startsWith('discovered_project_'));
expect(workspaceMcp).toHaveLength(1);
expect(workspaceMcp[0].name).toBe('manual');
expect(userDiscovered).toHaveLength(1);
expect(userDiscovered[0].name).toBe('user-srv');
expect(projectDiscovered).toHaveLength(1);
expect(projectDiscovered[0].name).toBe('proj-srv');
});
});
+108
View File
@@ -0,0 +1,108 @@
import { describe, expect, test } from 'bun:test';
import {
applyDiscoveredMcpServerStatus,
buildDiscoveredMcpServerFingerprint,
buildDiscoveredMcpServerId,
listAcceptedDiscoveredMcpServers,
listPendingDiscoveredMcpServers,
mergeDiscoveredToolingState,
normalizeDiscoveredToolingState,
type DiscoveredLocalMcpServer,
} from '@shared/domain/discoveredTooling';
const TIMESTAMP = '2026-03-25T00:00:00.000Z';
function createDiscoveredServer(overrides?: Partial<DiscoveredLocalMcpServer>): DiscoveredLocalMcpServer {
const server: DiscoveredLocalMcpServer = {
id: buildDiscoveredMcpServerId('project', 'project-alpha', 'vscode-mcp', 'Git MCP'),
name: 'Git MCP',
transport: 'local',
command: 'node',
args: ['server.js'],
cwd: 'C:\\workspace\\repo',
tools: ['git.status'],
scope: 'project',
scannerId: 'vscode-mcp',
sourcePath: 'C:\\workspace\\repo\\.vscode\\mcp.json',
sourceLabel: '.vscode\\mcp.json',
fingerprint: '',
status: 'pending',
};
return {
...server,
...overrides,
fingerprint: buildDiscoveredMcpServerFingerprint({
...server,
...overrides,
fingerprint: '',
status: 'pending',
}),
};
}
describe('discovered tooling helpers', () => {
test('normalizes discovered servers and preserves accepted status when the fingerprint is unchanged', () => {
const current = {
mcpServers: [
createDiscoveredServer({
status: 'accepted',
}),
],
lastScannedAt: TIMESTAMP,
};
const merged = mergeDiscoveredToolingState(current, [createDiscoveredServer()], '2026-03-25T01:00:00.000Z');
expect(merged.mcpServers[0]?.status).toBe('accepted');
expect(merged.lastScannedAt).toBe('2026-03-25T01:00:00.000Z');
});
test('marks changed servers as pending on re-scan', () => {
const current = {
mcpServers: [
createDiscoveredServer({
status: 'accepted',
}),
],
lastScannedAt: TIMESTAMP,
};
const merged = mergeDiscoveredToolingState(
current,
[
createDiscoveredServer({
args: ['server.js', '--debug'],
}),
],
'2026-03-25T01:00:00.000Z',
);
expect(merged.mcpServers[0]?.status).toBe('pending');
});
test('applies explicit accept and dismiss resolutions', () => {
const state = normalizeDiscoveredToolingState({
mcpServers: [
createDiscoveredServer(),
createDiscoveredServer({
id: buildDiscoveredMcpServerId('user', 'workspace', 'copilot-user-mcp', 'Git MCP'),
scope: 'user',
scannerId: 'copilot-user-mcp',
sourcePath: 'C:\\Users\\tester\\.copilot\\mcp.json',
sourceLabel: '~\\.copilot\\mcp.json',
}),
],
});
const accepted = applyDiscoveredMcpServerStatus(state, [state.mcpServers[0]!.id], 'accepted');
const dismissed = applyDiscoveredMcpServerStatus(accepted, [state.mcpServers[1]!.id], 'dismissed');
expect(listAcceptedDiscoveredMcpServers(dismissed).map((server) => server.id)).toEqual([
state.mcpServers[0]!.id,
]);
expect(listPendingDiscoveredMcpServers(dismissed)).toEqual([]);
expect(dismissed.mcpServers[1]?.status).toBe('dismissed');
});
});
+90
View File
@@ -3,6 +3,8 @@ import { describe, expect, test } from 'bun:test';
import {
listApprovalToolDefinitions,
normalizeWorkspaceSettings,
resolveProjectToolingSettings,
resolveWorkspaceToolingSettings,
validateLspProfileDefinition,
validateMcpServerDefinition,
type LspProfileDefinition,
@@ -23,6 +25,7 @@ describe('tooling settings helpers', () => {
command: ' node ',
args: [' --stdio ', ' --stdio ', ''],
cwd: ' C:\\workspace\\repo ',
env: { ' DEBUG ': ' true ', EMPTY: ' ' },
tools: [' git.status ', '', ' git.status '],
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
@@ -32,6 +35,7 @@ describe('tooling settings helpers', () => {
name: ' Remote MCP ',
transport: 'http',
url: ' https://example.com/mcp ',
headers: { ' Authorization ': ' Bearer token ', Empty: ' ' },
tools: [],
timeoutMs: 1000,
createdAt: TIMESTAMP,
@@ -64,6 +68,7 @@ describe('tooling settings helpers', () => {
command: 'node',
args: ['--stdio'],
cwd: 'C:\\workspace\\repo',
env: { DEBUG: 'true' },
tools: ['git.status'],
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
@@ -73,6 +78,7 @@ describe('tooling settings helpers', () => {
name: 'Remote MCP',
transport: 'http',
url: 'https://example.com/mcp',
headers: { Authorization: 'Bearer token' },
tools: [],
timeoutMs: 1000,
createdAt: TIMESTAMP,
@@ -92,6 +98,9 @@ describe('tooling settings helpers', () => {
},
],
},
discoveredUserTooling: {
mcpServers: [],
},
});
});
@@ -241,4 +250,85 @@ describe('tooling settings helpers', () => {
});
expect(tools.some((tool) => tool.id === 'web_fetch')).toBe(false);
});
test('resolves workspace and project tooling with accepted discovered MCP servers', () => {
const workspaceTooling = resolveWorkspaceToolingSettings(normalizeWorkspaceSettings({
tooling: {
mcpServers: [
{
id: 'manual',
name: 'Manual MCP',
transport: 'local',
command: 'node',
args: ['manual.js'],
tools: ['manual.tool'],
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
},
],
lspProfiles: [],
},
discoveredUserTooling: {
mcpServers: [
{
id: 'user-discovered',
name: 'User MCP',
transport: 'local',
command: 'node',
args: ['user.js'],
tools: ['user.tool'],
scope: 'user',
scannerId: 'copilot-user-mcp',
sourcePath: 'C:\\Users\\tester\\.copilot\\mcp.json',
sourceLabel: '~\\.copilot\\mcp.json',
fingerprint: 'fp-user',
status: 'accepted',
},
],
},
}));
expect(workspaceTooling.mcpServers.map((server) => server.id)).toEqual(['manual', 'user-discovered']);
const projectTooling = resolveProjectToolingSettings(
normalizeWorkspaceSettings({
tooling: {
mcpServers: [],
lspProfiles: [],
},
discoveredUserTooling: {
mcpServers: [],
},
}),
{
mcpServers: [
{
id: 'project-discovered',
name: 'Project MCP',
transport: 'http',
url: 'https://example.com/project',
headers: { Authorization: 'Bearer token' },
tools: ['project.tool'],
scope: 'project',
scannerId: 'vscode-mcp',
sourcePath: 'C:\\workspace\\repo\\.vscode\\mcp.json',
sourceLabel: '.vscode\\mcp.json',
fingerprint: 'fp-project',
status: 'accepted',
},
],
},
);
expect(projectTooling.mcpServers).toContainEqual({
id: 'project-discovered',
name: 'Project MCP',
transport: 'http',
url: 'https://example.com/project',
headers: { Authorization: 'Bearer token' },
tools: ['project.tool'],
createdAt: expect.any(String),
updatedAt: expect.any(String),
});
});
});
+3
View File
@@ -14,6 +14,9 @@ describe('workspace seed', () => {
mcpServers: [],
lspProfiles: [],
},
discoveredUserTooling: {
mcpServers: [],
},
});
expect(workspace.selectedProjectId).toBeUndefined();
expect(workspace.selectedPatternId).toBeUndefined();