mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-24 21:48:36 +02:00
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:
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[] => {
|
||||
|
||||
@@ -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
@@ -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'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));
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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')}`;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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: [
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,9 @@ describe('workspace seed', () => {
|
||||
mcpServers: [],
|
||||
lspProfiles: [],
|
||||
},
|
||||
discoveredUserTooling: {
|
||||
mcpServers: [],
|
||||
},
|
||||
});
|
||||
expect(workspace.selectedProjectId).toBeUndefined();
|
||||
expect(workspace.selectedPatternId).toBeUndefined();
|
||||
|
||||
Reference in New Issue
Block a user