mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-07 04:08:45 +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 int? TimeoutMs { get; init; }
|
||||||
public string? Command { get; init; }
|
public string? Command { get; init; }
|
||||||
public IReadOnlyList<string>? Args { get; init; }
|
public IReadOnlyList<string>? Args { get; init; }
|
||||||
|
public IReadOnlyDictionary<string, string>? Env { get; init; }
|
||||||
public string? Cwd { get; init; }
|
public string? Cwd { get; init; }
|
||||||
public string? Url { get; init; }
|
public string? Url { get; init; }
|
||||||
|
public IReadOnlyDictionary<string, string>? Headers { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class RunTurnLspProfileConfigDto
|
public sealed class RunTurnLspProfileConfigDto
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ internal sealed class SessionToolingBundle : IAsyncDisposable
|
|||||||
Timeout = server.TimeoutMs,
|
Timeout = server.TimeoutMs,
|
||||||
Command = server.Command,
|
Command = server.Command,
|
||||||
Args = server.Args?.ToList() ?? [],
|
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,
|
Cwd = string.IsNullOrWhiteSpace(server.Cwd) ? null : server.Cwd,
|
||||||
Tools = ResolveTools(server),
|
Tools = ResolveTools(server),
|
||||||
};
|
};
|
||||||
@@ -116,6 +117,7 @@ internal sealed class SessionToolingBundle : IAsyncDisposable
|
|||||||
Type = server.Transport,
|
Type = server.Transport,
|
||||||
Timeout = server.TimeoutMs,
|
Timeout = server.TimeoutMs,
|
||||||
Url = server.Url,
|
Url = server.Url,
|
||||||
|
Headers = server.Headers is null ? null : new Dictionary<string, string>(server.Headers, StringComparer.Ordinal),
|
||||||
Tools = ResolveTools(server),
|
Tools = ResolveTools(server),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ public sealed class SessionToolingBundleTests
|
|||||||
Transport = "local",
|
Transport = "local",
|
||||||
Command = "node",
|
Command = "node",
|
||||||
Args = ["server.js", "--stdio"],
|
Args = ["server.js", "--stdio"],
|
||||||
|
Env = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["DEBUG"] = "true",
|
||||||
|
},
|
||||||
Cwd = @"C:\workspace\repo",
|
Cwd = @"C:\workspace\repo",
|
||||||
Tools = ["git.status"],
|
Tools = ["git.status"],
|
||||||
TimeoutMs = 1500,
|
TimeoutMs = 1500,
|
||||||
@@ -28,6 +32,10 @@ public sealed class SessionToolingBundleTests
|
|||||||
Name = "Remote MCP",
|
Name = "Remote MCP",
|
||||||
Transport = "http",
|
Transport = "http",
|
||||||
Url = "https://example.com/mcp",
|
Url = "https://example.com/mcp",
|
||||||
|
Headers = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["Authorization"] = "Bearer token",
|
||||||
|
},
|
||||||
Tools = ["*"],
|
Tools = ["*"],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -38,6 +46,9 @@ public sealed class SessionToolingBundleTests
|
|||||||
Assert.Equal("local", localConfig.Type);
|
Assert.Equal("local", localConfig.Type);
|
||||||
Assert.Equal("node", localConfig.Command);
|
Assert.Equal("node", localConfig.Command);
|
||||||
Assert.Equal(["server.js", "--stdio"], localConfig.Args);
|
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(@"C:\workspace\repo", localConfig.Cwd);
|
||||||
Assert.Equal(["git.status"], localConfig.Tools);
|
Assert.Equal(["git.status"], localConfig.Tools);
|
||||||
Assert.Equal(1500, localConfig.Timeout);
|
Assert.Equal(1500, localConfig.Timeout);
|
||||||
@@ -45,6 +56,9 @@ public sealed class SessionToolingBundleTests
|
|||||||
McpRemoteServerConfig remoteConfig = Assert.IsType<McpRemoteServerConfig>(configurations["Remote MCP"]);
|
McpRemoteServerConfig remoteConfig = Assert.IsType<McpRemoteServerConfig>(configurations["Remote MCP"]);
|
||||||
Assert.Equal("http", remoteConfig.Type);
|
Assert.Equal("http", remoteConfig.Type);
|
||||||
Assert.Equal("https://example.com/mcp", remoteConfig.Url);
|
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);
|
Assert.Equal(["*"], remoteConfig.Tools);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+241
-15
@@ -24,6 +24,12 @@ import {
|
|||||||
type ReasoningEffort,
|
type ReasoningEffort,
|
||||||
validatePatternDefinition,
|
validatePatternDefinition,
|
||||||
} from '@shared/domain/pattern';
|
} from '@shared/domain/pattern';
|
||||||
|
import {
|
||||||
|
applyDiscoveredMcpServerStatus,
|
||||||
|
normalizeDiscoveredToolingState,
|
||||||
|
type DiscoveredToolingState,
|
||||||
|
type DiscoveredToolingStatus,
|
||||||
|
} from '@shared/domain/discoveredTooling';
|
||||||
import {
|
import {
|
||||||
approvalPolicyRequiresCheckpoint,
|
approvalPolicyRequiresCheckpoint,
|
||||||
dequeuePendingApprovalState,
|
dequeuePendingApprovalState,
|
||||||
@@ -71,6 +77,8 @@ import {
|
|||||||
createSessionToolingSelection,
|
createSessionToolingSelection,
|
||||||
listApprovalToolNames,
|
listApprovalToolNames,
|
||||||
normalizeTheme,
|
normalizeTheme,
|
||||||
|
resolveProjectToolingSettings,
|
||||||
|
resolveWorkspaceToolingSettings,
|
||||||
type AppearanceTheme,
|
type AppearanceTheme,
|
||||||
type LspProfileDefinition,
|
type LspProfileDefinition,
|
||||||
type McpServerDefinition,
|
type McpServerDefinition,
|
||||||
@@ -86,7 +94,11 @@ import { mergeStreamingText } from '@shared/utils/streamingText';
|
|||||||
|
|
||||||
import { WorkspaceRepository } from '@main/persistence/workspaceRepository';
|
import { WorkspaceRepository } from '@main/persistence/workspaceRepository';
|
||||||
import { SecretStore } from '@main/secrets/secretStore';
|
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 { GitService } from '@main/git/gitService';
|
||||||
import {
|
import {
|
||||||
buildRunTurnToolingConfig as buildSessionToolingConfig,
|
buildRunTurnToolingConfig as buildSessionToolingConfig,
|
||||||
@@ -104,6 +116,8 @@ type PendingApprovalHandle = {
|
|||||||
resolve: (decision: ApprovalDecision) => void | Promise<void>;
|
resolve: (decision: ApprovalDecision) => void | Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type DiscoveredToolingResolution = 'accept' | 'dismiss';
|
||||||
|
|
||||||
function isBuiltinPattern(patternId: string): boolean {
|
function isBuiltinPattern(patternId: string): boolean {
|
||||||
return patternId.startsWith('pattern-');
|
return patternId.startsWith('pattern-');
|
||||||
}
|
}
|
||||||
@@ -118,14 +132,20 @@ function equalStringArrays(left?: readonly string[], right?: readonly string[]):
|
|||||||
return normalizedLeft.every((value, index) => value === normalizedRight[index]);
|
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> {
|
export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
||||||
private readonly workspaceRepository = new WorkspaceRepository();
|
private readonly workspaceRepository = new WorkspaceRepository();
|
||||||
private readonly sidecar = new SidecarClient();
|
private readonly sidecar = new SidecarClient();
|
||||||
private readonly secretStore = new SecretStore();
|
private readonly secretStore = new SecretStore();
|
||||||
private readonly gitService = new GitService();
|
private readonly gitService = new GitService();
|
||||||
|
private readonly configScanner = new ConfigScannerRegistry();
|
||||||
private readonly pendingApprovalHandles = new Map<string, PendingApprovalHandle>();
|
private readonly pendingApprovalHandles = new Map<string, PendingApprovalHandle>();
|
||||||
private workspace?: WorkspaceState;
|
private workspace?: WorkspaceState;
|
||||||
private sidecarCapabilities?: SidecarCapabilities;
|
private sidecarCapabilities?: SidecarCapabilities;
|
||||||
|
private sidecarCapabilitiesPromise?: Promise<SidecarCapabilities>;
|
||||||
private didScheduleInitialProjectGitRefresh = false;
|
private didScheduleInitialProjectGitRefresh = false;
|
||||||
|
|
||||||
async describeSidecarCapabilities(): Promise<SidecarCapabilities> {
|
async describeSidecarCapabilities(): Promise<SidecarCapabilities> {
|
||||||
@@ -139,8 +159,23 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
async loadWorkspace(): Promise<WorkspaceState> {
|
async loadWorkspace(): Promise<WorkspaceState> {
|
||||||
if (!this.workspace) {
|
if (!this.workspace) {
|
||||||
this.workspace = await this.workspaceRepository.load();
|
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);
|
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);
|
await this.workspaceRepository.save(this.workspace);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,6 +212,7 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
|
|
||||||
this.workspace = undefined;
|
this.workspace = undefined;
|
||||||
this.sidecarCapabilities = undefined;
|
this.sidecarCapabilities = undefined;
|
||||||
|
this.sidecarCapabilitiesPromise = undefined;
|
||||||
this.didScheduleInitialProjectGitRefresh = false;
|
this.didScheduleInitialProjectGitRefresh = false;
|
||||||
|
|
||||||
return this.loadWorkspace();
|
return this.loadWorkspace();
|
||||||
@@ -197,6 +233,11 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
const existing = workspace.projects.find((project) => project.path === folderPath);
|
const existing = workspace.projects.find((project) => project.path === folderPath);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
workspace.selectedProjectId = existing.id;
|
workspace.selectedProjectId = existing.id;
|
||||||
|
const didSyncProjectTooling = await this.syncProjectDiscoveredTooling(workspace, existing);
|
||||||
|
if (didSyncProjectTooling) {
|
||||||
|
this.pruneUnavailableSessionToolingSelections(workspace);
|
||||||
|
await this.pruneUnavailableApprovalTools(workspace);
|
||||||
|
}
|
||||||
return this.persistAndBroadcast(workspace);
|
return this.persistAndBroadcast(workspace);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,6 +251,7 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
|
|
||||||
workspace.projects.push(project);
|
workspace.projects.push(project);
|
||||||
workspace.selectedProjectId = project.id;
|
workspace.selectedProjectId = project.id;
|
||||||
|
await this.syncProjectDiscoveredTooling(workspace, project);
|
||||||
return this.persistAndBroadcast(workspace);
|
return this.persistAndBroadcast(workspace);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,6 +278,49 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
return this.persistAndBroadcast(workspace);
|
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> {
|
async savePattern(pattern: PatternDefinition): Promise<WorkspaceState> {
|
||||||
const workspace = await this.loadWorkspace();
|
const workspace = await this.loadWorkspace();
|
||||||
const knownApprovalToolNames = await this.listKnownApprovalToolNames(workspace);
|
const knownApprovalToolNames = await this.listKnownApprovalToolNames(workspace);
|
||||||
@@ -675,7 +760,7 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
): Promise<WorkspaceState> {
|
): Promise<WorkspaceState> {
|
||||||
const workspace = await this.loadWorkspace();
|
const workspace = await this.loadWorkspace();
|
||||||
const session = this.requireSession(workspace, sessionId);
|
const session = this.requireSession(workspace, sessionId);
|
||||||
this.requireProject(workspace, session.projectId);
|
const project = this.requireProject(workspace, session.projectId);
|
||||||
|
|
||||||
if (session.status === 'running') {
|
if (session.status === 'running') {
|
||||||
throw new Error('Wait for the current response to finish before changing session tools.');
|
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,
|
enabledMcpServerIds,
|
||||||
enabledLspProfileIds,
|
enabledLspProfileIds,
|
||||||
});
|
});
|
||||||
validateSessionToolingSelectionIds(workspace.settings.tooling, selection);
|
validateSessionToolingSelectionIds(
|
||||||
|
resolveProjectToolingSettings(workspace.settings, project.discoveredTooling),
|
||||||
|
selection,
|
||||||
|
);
|
||||||
|
|
||||||
session.tooling = selection;
|
session.tooling = selection;
|
||||||
session.updatedAt = nowIso();
|
session.updatedAt = nowIso();
|
||||||
@@ -698,7 +786,7 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
): Promise<WorkspaceState> {
|
): Promise<WorkspaceState> {
|
||||||
const workspace = await this.loadWorkspace();
|
const workspace = await this.loadWorkspace();
|
||||||
const session = this.requireSession(workspace, sessionId);
|
const session = this.requireSession(workspace, sessionId);
|
||||||
this.requireProject(workspace, session.projectId);
|
const project = this.requireProject(workspace, session.projectId);
|
||||||
|
|
||||||
if (session.status === 'running') {
|
if (session.status === 'running') {
|
||||||
throw new Error('Wait for the current response to finish before changing session approval settings.');
|
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 },
|
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));
|
const unknownToolName = settings?.autoApprovedToolNames.find((toolName) => !knownToolNames.has(toolName));
|
||||||
if (unknownToolName) {
|
if (unknownToolName) {
|
||||||
throw new Error(`Unknown approval tool "${unknownToolName}".`);
|
throw new Error(`Unknown approval tool "${unknownToolName}".`);
|
||||||
@@ -741,6 +829,15 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
|
|
||||||
async selectProject(projectId?: string): Promise<WorkspaceState> {
|
async selectProject(projectId?: string): Promise<WorkspaceState> {
|
||||||
const workspace = await this.loadWorkspace();
|
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.selectedProjectId = projectId;
|
||||||
workspace.selectedSessionId = workspace.selectedSessionId;
|
workspace.selectedSessionId = workspace.selectedSessionId;
|
||||||
return this.persistAndBroadcast(workspace);
|
return this.persistAndBroadcast(workspace);
|
||||||
@@ -1177,18 +1274,29 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
return normalizePatternModels(patternWithApprovalSettings, modelCatalog);
|
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 capabilities = await this.loadSidecarCapabilities();
|
||||||
const runtimeTools = capabilities.runtimeTools.length > 0 ? capabilities.runtimeTools : undefined;
|
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> {
|
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;
|
let changed = false;
|
||||||
|
|
||||||
for (const pattern of workspace.patterns) {
|
for (const pattern of workspace.patterns) {
|
||||||
const nextPolicy = pruneApprovalPolicyTools(pattern.approvalPolicy, knownToolNames);
|
const nextPolicy = pruneApprovalPolicyTools(pattern.approvalPolicy, workspaceKnownToolNames);
|
||||||
if (!equalStringArrays(
|
if (!equalStringArrays(
|
||||||
pattern.approvalPolicy?.autoApprovedToolNames,
|
pattern.approvalPolicy?.autoApprovedToolNames,
|
||||||
nextPolicy?.autoApprovedToolNames,
|
nextPolicy?.autoApprovedToolNames,
|
||||||
@@ -1199,6 +1307,11 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const session of workspace.sessions) {
|
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(
|
const nextSettings = pruneSessionApprovalSettings(
|
||||||
session.approvalSettings,
|
session.approvalSettings,
|
||||||
knownToolNames,
|
knownToolNames,
|
||||||
@@ -1219,9 +1332,89 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
workspace: WorkspaceState,
|
workspace: WorkspaceState,
|
||||||
session: SessionRecord,
|
session: SessionRecord,
|
||||||
): RunTurnToolingConfig | undefined {
|
): RunTurnToolingConfig | undefined {
|
||||||
|
const project = this.requireProject(workspace, session.projectId);
|
||||||
|
const tooling = resolveProjectToolingSettings(workspace.settings, project.discoveredTooling);
|
||||||
const selection = resolveSessionToolingSelection(session);
|
const selection = resolveSessionToolingSelection(session);
|
||||||
validateSessionToolingSelectionIds(workspace.settings.tooling, selection);
|
validateSessionToolingSelectionIds(tooling, selection);
|
||||||
return buildSessionToolingConfig(workspace.settings.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(
|
private updateSessionRun(
|
||||||
@@ -1300,10 +1493,43 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async loadSidecarCapabilities(forceRefresh = false): Promise<SidecarCapabilities> {
|
private async loadSidecarCapabilities(forceRefresh = false): Promise<SidecarCapabilities> {
|
||||||
if (forceRefresh || !this.sidecarCapabilities) {
|
if (forceRefresh) {
|
||||||
this.sidecarCapabilities = await this.sidecar.describeCapabilities();
|
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 { ipcChannels } from '@shared/contracts/channels';
|
||||||
import type {
|
import type {
|
||||||
CreateSessionInput,
|
CreateSessionInput,
|
||||||
|
ResolveProjectDiscoveredToolingInput,
|
||||||
|
ResolveWorkspaceDiscoveredToolingInput,
|
||||||
DuplicateSessionInput,
|
DuplicateSessionInput,
|
||||||
RenameSessionInput,
|
RenameSessionInput,
|
||||||
|
RescanProjectConfigsInput,
|
||||||
ResolveSessionApprovalInput,
|
ResolveSessionApprovalInput,
|
||||||
SaveLspProfileInput,
|
SaveLspProfileInput,
|
||||||
SaveMcpServerInput,
|
SaveMcpServerInput,
|
||||||
@@ -29,9 +32,22 @@ export function registerIpcHandlers(window: BrowserWindow, service: EryxAppServi
|
|||||||
ipcMain.handle(ipcChannels.loadWorkspace, () => service.loadWorkspace());
|
ipcMain.handle(ipcChannels.loadWorkspace, () => service.loadWorkspace());
|
||||||
ipcMain.handle(ipcChannels.addProject, () => service.addProject());
|
ipcMain.handle(ipcChannels.addProject, () => service.addProject());
|
||||||
ipcMain.handle(ipcChannels.removeProject, (_event, projectId: string) => service.removeProject(projectId));
|
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) =>
|
ipcMain.handle(ipcChannels.refreshProjectGitContext, (_event, projectId?: string) =>
|
||||||
service.refreshProjectGitContext(projectId),
|
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.savePattern, (_event, input: SavePatternInput) => service.savePattern(input.pattern));
|
||||||
ipcMain.handle(ipcChannels.deletePattern, (_event, patternId: string) => service.deletePattern(patternId));
|
ipcMain.handle(ipcChannels.deletePattern, (_event, patternId: string) => service.deletePattern(patternId));
|
||||||
ipcMain.handle(ipcChannels.setPatternFavorite, (_event, input: SetPatternFavoriteInput) =>
|
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 { createBuiltinPatterns, resolvePatternGraph } from '@shared/domain/pattern';
|
||||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||||
import { mergeScratchpadProject } from '@shared/domain/project';
|
import { mergeScratchpadProject } from '@shared/domain/project';
|
||||||
|
import { normalizeDiscoveredToolingState } from '@shared/domain/discoveredTooling';
|
||||||
import { normalizeSessionRunRecords } from '@shared/domain/runTimeline';
|
import { normalizeSessionRunRecords } from '@shared/domain/runTimeline';
|
||||||
import {
|
import {
|
||||||
normalizeSessionToolingSelection,
|
normalizeSessionToolingSelection,
|
||||||
@@ -63,7 +64,13 @@ export class WorkspaceRepository {
|
|||||||
return seeded;
|
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 settings = normalizeWorkspaceSettings(stored.settings);
|
||||||
|
|
||||||
const workspace: WorkspaceState = {
|
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,
|
command: server.command,
|
||||||
args: [...server.args],
|
args: [...server.args],
|
||||||
cwd: server.cwd,
|
cwd: server.cwd,
|
||||||
|
env: server.env ? { ...server.env } : undefined,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -63,12 +64,13 @@ export function buildRunTurnToolingConfig(
|
|||||||
{
|
{
|
||||||
id: server.id,
|
id: server.id,
|
||||||
name: server.name,
|
name: server.name,
|
||||||
transport: server.transport,
|
transport: server.transport,
|
||||||
tools: [...server.tools],
|
tools: [...server.tools],
|
||||||
timeoutMs: server.timeoutMs,
|
timeoutMs: server.timeoutMs,
|
||||||
url: server.url,
|
url: server.url,
|
||||||
},
|
headers: server.headers ? { ...server.headers } : undefined,
|
||||||
];
|
},
|
||||||
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
const lspProfiles = selection.enabledLspProfileIds.flatMap((id): RunTurnLspProfileConfig[] => {
|
const lspProfiles = selection.enabledLspProfileIds.flatMap((id): RunTurnLspProfileConfig[] => {
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ const api: ElectronApi = {
|
|||||||
loadWorkspace: () => ipcRenderer.invoke(ipcChannels.loadWorkspace),
|
loadWorkspace: () => ipcRenderer.invoke(ipcChannels.loadWorkspace),
|
||||||
addProject: () => ipcRenderer.invoke(ipcChannels.addProject),
|
addProject: () => ipcRenderer.invoke(ipcChannels.addProject),
|
||||||
removeProject: (projectId) => ipcRenderer.invoke(ipcChannels.removeProject, projectId),
|
removeProject: (projectId) => ipcRenderer.invoke(ipcChannels.removeProject, projectId),
|
||||||
|
resolveWorkspaceDiscoveredTooling: (input) =>
|
||||||
|
ipcRenderer.invoke(ipcChannels.resolveWorkspaceDiscoveredTooling, input),
|
||||||
refreshProjectGitContext: (projectId) => ipcRenderer.invoke(ipcChannels.refreshProjectGitContext, projectId),
|
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),
|
savePattern: (input) => ipcRenderer.invoke(ipcChannels.savePattern, input),
|
||||||
deletePattern: (patternId) => ipcRenderer.invoke(ipcChannels.deletePattern, patternId),
|
deletePattern: (patternId) => ipcRenderer.invoke(ipcChannels.deletePattern, patternId),
|
||||||
setPatternFavorite: (input) => ipcRenderer.invoke(ipcChannels.setPatternFavorite, input),
|
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 { AppShell } from '@renderer/components/AppShell';
|
||||||
import { ActivityPanel } from '@renderer/components/ActivityPanel';
|
import { ActivityPanel } from '@renderer/components/ActivityPanel';
|
||||||
import { ChatPane } from '@renderer/components/ChatPane';
|
import { ChatPane } from '@renderer/components/ChatPane';
|
||||||
|
import { DiscoveredToolingModal } from '@renderer/components/DiscoveredToolingModal';
|
||||||
import { NewSessionModal } from '@renderer/components/NewSessionModal';
|
import { NewSessionModal } from '@renderer/components/NewSessionModal';
|
||||||
import { SettingsPanel } from '@renderer/components/SettingsPanel';
|
import { SettingsPanel } from '@renderer/components/SettingsPanel';
|
||||||
import { Sidebar } from '@renderer/components/Sidebar';
|
import { Sidebar } from '@renderer/components/Sidebar';
|
||||||
@@ -22,10 +23,12 @@ import {
|
|||||||
resolveReasoningEffort,
|
resolveReasoningEffort,
|
||||||
} from '@shared/domain/models';
|
} from '@shared/domain/models';
|
||||||
import { createDefaultToolApprovalPolicy } from '@shared/domain/approval';
|
import { createDefaultToolApprovalPolicy } from '@shared/domain/approval';
|
||||||
|
import { listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling';
|
||||||
import { syncPatternGraph, type PatternDefinition } from '@shared/domain/pattern';
|
import { syncPatternGraph, type PatternDefinition } from '@shared/domain/pattern';
|
||||||
import { isScratchpadProject, SCRATCHPAD_PROJECT_ID } from '@shared/domain/project';
|
import { isScratchpadProject, SCRATCHPAD_PROJECT_ID } from '@shared/domain/project';
|
||||||
import { applyScratchpadSessionConfig } from '@shared/domain/session';
|
import { applyScratchpadSessionConfig } from '@shared/domain/session';
|
||||||
import type { AppearanceTheme, LspProfileDefinition, McpServerDefinition } from '@shared/domain/tooling';
|
import type { AppearanceTheme, LspProfileDefinition, McpServerDefinition } from '@shared/domain/tooling';
|
||||||
|
import { resolveProjectToolingSettings } from '@shared/domain/tooling';
|
||||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||||
import { createId, nowIso } from '@shared/utils/ids';
|
import { createId, nowIso } from '@shared/utils/ids';
|
||||||
|
|
||||||
@@ -91,6 +94,7 @@ export default function App() {
|
|||||||
|
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
const [newSessionProjectId, setNewSessionProjectId] = useState<string>();
|
const [newSessionProjectId, setNewSessionProjectId] = useState<string>();
|
||||||
|
const [showDiscoveryModal, setShowDiscoveryModal] = useState(false);
|
||||||
|
|
||||||
// Load workspace on mount
|
// Load workspace on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -170,6 +174,28 @@ export default function App() {
|
|||||||
[workspace?.projects],
|
[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 jumpToMessage = useCallback((messageId: string) => {
|
||||||
const element = document.querySelector(`[data-message-id="${CSS.escape(messageId)}"]`);
|
const element = document.querySelector(`[data-message-id="${CSS.escape(messageId)}"]`);
|
||||||
if (element) {
|
if (element) {
|
||||||
@@ -248,7 +274,7 @@ export default function App() {
|
|||||||
project={projectForSession}
|
project={projectForSession}
|
||||||
runtimeTools={sidecarCapabilities?.runtimeTools}
|
runtimeTools={sidecarCapabilities?.runtimeTools}
|
||||||
session={selectedSession}
|
session={selectedSession}
|
||||||
toolingSettings={workspace.settings.tooling}
|
toolingSettings={effectiveTooling ?? workspace.settings.tooling}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
detailPanel = (
|
detailPanel = (
|
||||||
@@ -314,6 +340,16 @@ export default function App() {
|
|||||||
sidecarCapabilities={sidecarCapabilities}
|
sidecarCapabilities={sidecarCapabilities}
|
||||||
theme={workspace.settings.theme}
|
theme={workspace.settings.theme}
|
||||||
toolingSettings={workspace.settings.tooling}
|
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;
|
) : null;
|
||||||
|
|
||||||
@@ -372,6 +408,23 @@ export default function App() {
|
|||||||
projects={workspace.projects}
|
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 { 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 { CopilotStatusCard } from '@renderer/components/CopilotStatusCard';
|
||||||
import { PatternEditor } from '@renderer/components/PatternEditor';
|
import { PatternEditor } from '@renderer/components/PatternEditor';
|
||||||
import { LspProfileEditor } from '@renderer/components/settings/LspProfileEditor';
|
import { LspProfileEditor } from '@renderer/components/settings/LspProfileEditor';
|
||||||
import { McpServerEditor } from '@renderer/components/settings/McpServerEditor';
|
import { McpServerEditor } from '@renderer/components/settings/McpServerEditor';
|
||||||
import type { SidecarCapabilities } from '@shared/contracts/sidecar';
|
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 { ModelDefinition } from '@shared/domain/models';
|
||||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||||
import {
|
import {
|
||||||
@@ -23,6 +25,9 @@ interface SettingsPanelProps {
|
|||||||
sidecarCapabilities?: SidecarCapabilities;
|
sidecarCapabilities?: SidecarCapabilities;
|
||||||
theme: AppearanceTheme;
|
theme: AppearanceTheme;
|
||||||
toolingSettings: WorkspaceToolingSettings;
|
toolingSettings: WorkspaceToolingSettings;
|
||||||
|
discoveredUserTooling: DiscoveredToolingState;
|
||||||
|
discoveredProjectTooling?: ProjectDiscoveredTooling;
|
||||||
|
selectedProjectName?: string;
|
||||||
isRefreshingCapabilities: boolean;
|
isRefreshingCapabilities: boolean;
|
||||||
onRefreshCapabilities: () => void;
|
onRefreshCapabilities: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -38,6 +43,9 @@ interface SettingsPanelProps {
|
|||||||
onSetTheme: (theme: AppearanceTheme) => void;
|
onSetTheme: (theme: AppearanceTheme) => void;
|
||||||
onOpenAppDataFolder: () => void;
|
onOpenAppDataFolder: () => void;
|
||||||
onResetLocalWorkspace: () => Promise<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';
|
type SettingsSection = 'appearance' | 'connection' | 'patterns' | 'mcp-servers' | 'lsp-profiles' | 'troubleshooting';
|
||||||
@@ -98,6 +106,9 @@ export function SettingsPanel({
|
|||||||
sidecarCapabilities,
|
sidecarCapabilities,
|
||||||
theme,
|
theme,
|
||||||
toolingSettings,
|
toolingSettings,
|
||||||
|
discoveredUserTooling,
|
||||||
|
discoveredProjectTooling,
|
||||||
|
selectedProjectName,
|
||||||
isRefreshingCapabilities,
|
isRefreshingCapabilities,
|
||||||
onRefreshCapabilities,
|
onRefreshCapabilities,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -113,6 +124,9 @@ export function SettingsPanel({
|
|||||||
onSetTheme,
|
onSetTheme,
|
||||||
onOpenAppDataFolder,
|
onOpenAppDataFolder,
|
||||||
onResetLocalWorkspace,
|
onResetLocalWorkspace,
|
||||||
|
onRescanProjectConfigs,
|
||||||
|
onResolveUserDiscoveredTooling,
|
||||||
|
onResolveProjectDiscoveredTooling,
|
||||||
}: SettingsPanelProps) {
|
}: SettingsPanelProps) {
|
||||||
const [activeSection, setActiveSection] = useState<SettingsSection>('appearance');
|
const [activeSection, setActiveSection] = useState<SettingsSection>('appearance');
|
||||||
const [editingPattern, setEditingPattern] = useState<PatternDefinition | null>(null);
|
const [editingPattern, setEditingPattern] = useState<PatternDefinition | null>(null);
|
||||||
@@ -271,6 +285,16 @@ export function SettingsPanel({
|
|||||||
servers={toolingSettings.mcpServers}
|
servers={toolingSettings.mcpServers}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{activeSection === 'mcp-servers' && (
|
||||||
|
<DiscoveredMcpSection
|
||||||
|
discoveredProjectTooling={discoveredProjectTooling}
|
||||||
|
discoveredUserTooling={discoveredUserTooling}
|
||||||
|
onRescanProjectConfigs={onRescanProjectConfigs}
|
||||||
|
onResolveProjectDiscoveredTooling={onResolveProjectDiscoveredTooling}
|
||||||
|
onResolveUserDiscoveredTooling={onResolveUserDiscoveredTooling}
|
||||||
|
selectedProjectName={selectedProjectName}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{activeSection === 'lsp-profiles' && (
|
{activeSection === 'lsp-profiles' && (
|
||||||
<LspProfilesSection
|
<LspProfilesSection
|
||||||
onEditProfile={(profile) => setEditingLspProfile(structuredClone(profile))}
|
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({
|
function TroubleshootingSection({
|
||||||
onOpenAppDataFolder,
|
onOpenAppDataFolder,
|
||||||
onResetLocalWorkspace,
|
onResetLocalWorkspace,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
|
|
||||||
import type { OrchestrationMode, PatternDefinition } from '@shared/domain/pattern';
|
import type { OrchestrationMode, PatternDefinition } from '@shared/domain/pattern';
|
||||||
import { isScratchpadProject, type ProjectRecord, type ProjectGitContext } from '@shared/domain/project';
|
import { isScratchpadProject, type ProjectRecord, type ProjectGitContext } from '@shared/domain/project';
|
||||||
|
import { listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling';
|
||||||
import type { SessionRecord } from '@shared/domain/session';
|
import type { SessionRecord } from '@shared/domain/session';
|
||||||
import { querySessions } from '@shared/domain/sessionLibrary';
|
import { querySessions } from '@shared/domain/sessionLibrary';
|
||||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||||
@@ -360,6 +361,10 @@ function ProjectGroup({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const runningCount = visibleSessions.filter((s) => s.status === 'running').length;
|
const runningCount = visibleSessions.filter((s) => s.status === 'running').length;
|
||||||
|
const pendingDiscoveryCount = useMemo(
|
||||||
|
() => isScratchpad ? 0 : listPendingDiscoveredMcpServers(project.discoveredTooling).length,
|
||||||
|
[isScratchpad, project.discoveredTooling],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -404,6 +409,14 @@ function ProjectGroup({
|
|||||||
{runningCount}
|
{runningCount}
|
||||||
</span>
|
</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">
|
<span className="rounded-full bg-zinc-800 px-1.5 py-0.5 text-[10px] font-medium text-zinc-500">
|
||||||
{visibleSessions.length}
|
{visibleSessions.length}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -225,6 +225,10 @@ export function InlineToolsPill({
|
|||||||
const enabledCount = selection.enabledMcpServerIds.length + selection.enabledLspProfileIds.length;
|
const enabledCount = selection.enabledMcpServerIds.length + selection.enabledLspProfileIds.length;
|
||||||
const totalCount = mcpServers.length + lspProfiles.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 (
|
return (
|
||||||
<div className="relative" ref={ref}>
|
<div className="relative" ref={ref}>
|
||||||
<button
|
<button
|
||||||
@@ -246,26 +250,29 @@ export function InlineToolsPill({
|
|||||||
|
|
||||||
{open && !disabled && (
|
{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">
|
<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 && (
|
{workspaceMcpServers.length > 0 && (
|
||||||
<div>
|
<McpServerGroup
|
||||||
<div className="px-3 pb-1 pt-2 text-[9px] font-semibold uppercase tracking-wider text-zinc-600">
|
label="Workspace MCP"
|
||||||
MCP Servers
|
onToggle={onToggle}
|
||||||
</div>
|
selection={selection}
|
||||||
{mcpServers.map((server) => (
|
servers={workspaceMcpServers}
|
||||||
<PopoverToggleRow
|
/>
|
||||||
detail={server.transport === 'local' ? server.command : server.url}
|
)}
|
||||||
enabled={selection.enabledMcpServerIds.includes(server.id)}
|
{discoveredUserMcpServers.length > 0 && (
|
||||||
key={server.id}
|
<McpServerGroup
|
||||||
label={server.name}
|
label="User MCP"
|
||||||
onToggle={() =>
|
onToggle={onToggle}
|
||||||
onToggle({
|
selection={selection}
|
||||||
...selection,
|
servers={discoveredUserMcpServers}
|
||||||
enabledMcpServerIds: toggleInArray(selection.enabledMcpServerIds, server.id),
|
/>
|
||||||
})
|
)}
|
||||||
}
|
{discoveredProjectMcpServers.length > 0 && (
|
||||||
/>
|
<McpServerGroup
|
||||||
))}
|
label="Project MCP"
|
||||||
</div>
|
onToggle={onToggle}
|
||||||
|
selection={selection}
|
||||||
|
servers={discoveredProjectMcpServers}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{lspProfiles.length > 0 && (
|
{lspProfiles.length > 0 && (
|
||||||
<div>
|
<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 ────────────────────────────────────── */
|
/* ── InlineApprovalPill ────────────────────────────────────── */
|
||||||
|
|
||||||
const approvalKindOrder: ApprovalToolKind[] = ['builtin', 'mcp', 'lsp', 'mixed'];
|
const approvalKindOrder: ApprovalToolKind[] = ['builtin', 'mcp', 'lsp', 'mixed'];
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ export const ipcChannels = {
|
|||||||
loadWorkspace: 'workspace:load',
|
loadWorkspace: 'workspace:load',
|
||||||
addProject: 'workspace:add-project',
|
addProject: 'workspace:add-project',
|
||||||
removeProject: 'workspace:remove-project',
|
removeProject: 'workspace:remove-project',
|
||||||
|
resolveWorkspaceDiscoveredTooling: 'workspace:resolve-discovered-tooling',
|
||||||
refreshProjectGitContext: 'projects:refresh-git-context',
|
refreshProjectGitContext: 'projects:refresh-git-context',
|
||||||
|
rescanProjectConfigs: 'project:rescan-configs',
|
||||||
|
resolveProjectDiscoveredTooling: 'project:resolve-discovered-tooling',
|
||||||
savePattern: 'patterns:save',
|
savePattern: 'patterns:save',
|
||||||
deletePattern: 'patterns:delete',
|
deletePattern: 'patterns:delete',
|
||||||
setPatternFavorite: 'patterns:set-favorite',
|
setPatternFavorite: 'patterns:set-favorite',
|
||||||
|
|||||||
@@ -70,6 +70,23 @@ export interface SaveLspProfileInput {
|
|||||||
profile: LspProfileDefinition;
|
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 {
|
export interface UpdateSessionToolingInput extends SessionToolingSelection {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
}
|
}
|
||||||
@@ -85,7 +102,10 @@ export interface ElectronApi {
|
|||||||
loadWorkspace(): Promise<WorkspaceState>;
|
loadWorkspace(): Promise<WorkspaceState>;
|
||||||
addProject(): Promise<WorkspaceState>;
|
addProject(): Promise<WorkspaceState>;
|
||||||
removeProject(projectId: string): Promise<WorkspaceState>;
|
removeProject(projectId: string): Promise<WorkspaceState>;
|
||||||
|
resolveWorkspaceDiscoveredTooling(input: ResolveWorkspaceDiscoveredToolingInput): Promise<WorkspaceState>;
|
||||||
refreshProjectGitContext(projectId?: string): Promise<WorkspaceState>;
|
refreshProjectGitContext(projectId?: string): Promise<WorkspaceState>;
|
||||||
|
rescanProjectConfigs(input: RescanProjectConfigsInput): Promise<WorkspaceState>;
|
||||||
|
resolveProjectDiscoveredTooling(input: ResolveProjectDiscoveredToolingInput): Promise<WorkspaceState>;
|
||||||
savePattern(input: SavePatternInput): Promise<WorkspaceState>;
|
savePattern(input: SavePatternInput): Promise<WorkspaceState>;
|
||||||
deletePattern(patternId: string): Promise<WorkspaceState>;
|
deletePattern(patternId: string): Promise<WorkspaceState>;
|
||||||
saveMcpServer(input: SaveMcpServerInput): Promise<WorkspaceState>;
|
saveMcpServer(input: SaveMcpServerInput): Promise<WorkspaceState>;
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ export interface RunTurnLocalMcpServerConfig {
|
|||||||
command: string;
|
command: string;
|
||||||
args: string[];
|
args: string[];
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
|
env?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RunTurnRemoteMcpServerConfig {
|
export interface RunTurnRemoteMcpServerConfig {
|
||||||
@@ -110,6 +111,7 @@ export interface RunTurnRemoteMcpServerConfig {
|
|||||||
tools: string[];
|
tools: string[];
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
url: string;
|
url: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RunTurnMcpServerConfig = RunTurnLocalMcpServerConfig | RunTurnRemoteMcpServerConfig;
|
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 { nowIso } from '@shared/utils/ids';
|
||||||
|
import type { ProjectDiscoveredTooling } from '@shared/domain/discoveredTooling';
|
||||||
|
|
||||||
export type ProjectGitContextStatus = 'ready' | 'not-repository' | 'git-missing' | 'error';
|
export type ProjectGitContextStatus = 'ready' | 'not-repository' | 'git-missing' | 'error';
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@ export interface ProjectRecord {
|
|||||||
path: string;
|
path: string;
|
||||||
addedAt: string;
|
addedAt: string;
|
||||||
git?: ProjectGitContext;
|
git?: ProjectGitContext;
|
||||||
|
discoveredTooling?: ProjectDiscoveredTooling;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SCRATCHPAD_PROJECT_ID = 'project-scratchpad';
|
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 type McpServerTransport = 'local' | 'http' | 'sse';
|
||||||
|
|
||||||
export interface BaseMcpServerDefinition {
|
export interface BaseMcpServerDefinition {
|
||||||
@@ -15,11 +24,13 @@ export interface LocalMcpServerDefinition extends BaseMcpServerDefinition {
|
|||||||
command: string;
|
command: string;
|
||||||
args: string[];
|
args: string[];
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
|
env?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RemoteMcpServerDefinition extends BaseMcpServerDefinition {
|
export interface RemoteMcpServerDefinition extends BaseMcpServerDefinition {
|
||||||
transport: 'http' | 'sse';
|
transport: 'http' | 'sse';
|
||||||
url: string;
|
url: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type McpServerDefinition = LocalMcpServerDefinition | RemoteMcpServerDefinition;
|
export type McpServerDefinition = LocalMcpServerDefinition | RemoteMcpServerDefinition;
|
||||||
@@ -45,6 +56,7 @@ export type AppearanceTheme = 'dark' | 'light' | 'system';
|
|||||||
export interface WorkspaceSettings {
|
export interface WorkspaceSettings {
|
||||||
theme: AppearanceTheme;
|
theme: AppearanceTheme;
|
||||||
tooling: WorkspaceToolingSettings;
|
tooling: WorkspaceToolingSettings;
|
||||||
|
discoveredUserTooling: DiscoveredToolingState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionToolingSelection {
|
export interface SessionToolingSelection {
|
||||||
@@ -95,6 +107,7 @@ export function createWorkspaceSettings(): WorkspaceSettings {
|
|||||||
mcpServers: [],
|
mcpServers: [],
|
||||||
lspProfiles: [],
|
lspProfiles: [],
|
||||||
},
|
},
|
||||||
|
discoveredUserTooling: createDiscoveredToolingState(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,9 +131,27 @@ export function normalizeWorkspaceSettings(settings?: Partial<WorkspaceSettings>
|
|||||||
mcpServers: (settings?.tooling?.mcpServers ?? []).map(normalizeMcpServerDefinition),
|
mcpServers: (settings?.tooling?.mcpServers ?? []).map(normalizeMcpServerDefinition),
|
||||||
lspProfiles: (settings?.tooling?.lspProfiles ?? []).map(normalizeLspProfileDefinition),
|
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(
|
export function normalizeSessionToolingSelection(
|
||||||
selection?: Partial<SessionToolingSelection>,
|
selection?: Partial<SessionToolingSelection>,
|
||||||
): SessionToolingSelection {
|
): SessionToolingSelection {
|
||||||
@@ -249,6 +280,7 @@ export function normalizeMcpServerDefinition(server: McpServerDefinition): McpSe
|
|||||||
command: server.command.trim(),
|
command: server.command.trim(),
|
||||||
args: normalizeStringArray(server.args),
|
args: normalizeStringArray(server.args),
|
||||||
cwd: server.cwd?.trim() || undefined,
|
cwd: server.cwd?.trim() || undefined,
|
||||||
|
env: normalizeStringRecord(server.env),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,6 +288,7 @@ export function normalizeMcpServerDefinition(server: McpServerDefinition): McpSe
|
|||||||
...base,
|
...base,
|
||||||
transport: server.transport,
|
transport: server.transport,
|
||||||
url: server.url.trim(),
|
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}`));
|
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 {
|
function requiresTypeScriptLanguageServerStdio(command: string): boolean {
|
||||||
const executableName = command
|
const executableName = command
|
||||||
.trim()
|
.trim()
|
||||||
@@ -350,3 +434,20 @@ function normalizeStringArray(values?: ReadonlyArray<string>): string[] {
|
|||||||
|
|
||||||
return [...new Set(values.map((value) => value.trim()).filter((value) => value.length > 0))];
|
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',
|
command: 'node',
|
||||||
args: ['server.js'],
|
args: ['server.js'],
|
||||||
cwd: 'C:\\workspace\\repo',
|
cwd: 'C:\\workspace\\repo',
|
||||||
|
env: { DEBUG: 'true' },
|
||||||
tools: ['git.status'],
|
tools: ['git.status'],
|
||||||
createdAt: TIMESTAMP,
|
createdAt: TIMESTAMP,
|
||||||
updatedAt: 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: [
|
lspProfiles: [
|
||||||
{
|
{
|
||||||
@@ -61,7 +72,7 @@ describe('session tooling config helpers', () => {
|
|||||||
test('builds a run-turn tooling config from selected MCP and LSP ids', () => {
|
test('builds a run-turn tooling config from selected MCP and LSP ids', () => {
|
||||||
expect(
|
expect(
|
||||||
buildRunTurnToolingConfig(TOOLING, {
|
buildRunTurnToolingConfig(TOOLING, {
|
||||||
enabledMcpServerIds: ['mcp-git'],
|
enabledMcpServerIds: ['mcp-git', 'mcp-remote'],
|
||||||
enabledLspProfileIds: ['lsp-ts'],
|
enabledLspProfileIds: ['lsp-ts'],
|
||||||
}),
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
@@ -74,6 +85,15 @@ describe('session tooling config helpers', () => {
|
|||||||
command: 'node',
|
command: 'node',
|
||||||
args: ['server.js'],
|
args: ['server.js'],
|
||||||
cwd: 'C:\\workspace\\repo',
|
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: [
|
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 {
|
import {
|
||||||
listApprovalToolDefinitions,
|
listApprovalToolDefinitions,
|
||||||
normalizeWorkspaceSettings,
|
normalizeWorkspaceSettings,
|
||||||
|
resolveProjectToolingSettings,
|
||||||
|
resolveWorkspaceToolingSettings,
|
||||||
validateLspProfileDefinition,
|
validateLspProfileDefinition,
|
||||||
validateMcpServerDefinition,
|
validateMcpServerDefinition,
|
||||||
type LspProfileDefinition,
|
type LspProfileDefinition,
|
||||||
@@ -23,6 +25,7 @@ describe('tooling settings helpers', () => {
|
|||||||
command: ' node ',
|
command: ' node ',
|
||||||
args: [' --stdio ', ' --stdio ', ''],
|
args: [' --stdio ', ' --stdio ', ''],
|
||||||
cwd: ' C:\\workspace\\repo ',
|
cwd: ' C:\\workspace\\repo ',
|
||||||
|
env: { ' DEBUG ': ' true ', EMPTY: ' ' },
|
||||||
tools: [' git.status ', '', ' git.status '],
|
tools: [' git.status ', '', ' git.status '],
|
||||||
createdAt: TIMESTAMP,
|
createdAt: TIMESTAMP,
|
||||||
updatedAt: TIMESTAMP,
|
updatedAt: TIMESTAMP,
|
||||||
@@ -32,6 +35,7 @@ describe('tooling settings helpers', () => {
|
|||||||
name: ' Remote MCP ',
|
name: ' Remote MCP ',
|
||||||
transport: 'http',
|
transport: 'http',
|
||||||
url: ' https://example.com/mcp ',
|
url: ' https://example.com/mcp ',
|
||||||
|
headers: { ' Authorization ': ' Bearer token ', Empty: ' ' },
|
||||||
tools: [],
|
tools: [],
|
||||||
timeoutMs: 1000,
|
timeoutMs: 1000,
|
||||||
createdAt: TIMESTAMP,
|
createdAt: TIMESTAMP,
|
||||||
@@ -64,6 +68,7 @@ describe('tooling settings helpers', () => {
|
|||||||
command: 'node',
|
command: 'node',
|
||||||
args: ['--stdio'],
|
args: ['--stdio'],
|
||||||
cwd: 'C:\\workspace\\repo',
|
cwd: 'C:\\workspace\\repo',
|
||||||
|
env: { DEBUG: 'true' },
|
||||||
tools: ['git.status'],
|
tools: ['git.status'],
|
||||||
createdAt: TIMESTAMP,
|
createdAt: TIMESTAMP,
|
||||||
updatedAt: TIMESTAMP,
|
updatedAt: TIMESTAMP,
|
||||||
@@ -73,6 +78,7 @@ describe('tooling settings helpers', () => {
|
|||||||
name: 'Remote MCP',
|
name: 'Remote MCP',
|
||||||
transport: 'http',
|
transport: 'http',
|
||||||
url: 'https://example.com/mcp',
|
url: 'https://example.com/mcp',
|
||||||
|
headers: { Authorization: 'Bearer token' },
|
||||||
tools: [],
|
tools: [],
|
||||||
timeoutMs: 1000,
|
timeoutMs: 1000,
|
||||||
createdAt: TIMESTAMP,
|
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);
|
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: [],
|
mcpServers: [],
|
||||||
lspProfiles: [],
|
lspProfiles: [],
|
||||||
},
|
},
|
||||||
|
discoveredUserTooling: {
|
||||||
|
mcpServers: [],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
expect(workspace.selectedProjectId).toBeUndefined();
|
expect(workspace.selectedProjectId).toBeUndefined();
|
||||||
expect(workspace.selectedPatternId).toBeUndefined();
|
expect(workspace.selectedPatternId).toBeUndefined();
|
||||||
|
|||||||
Reference in New Issue
Block a user