mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-28 13:47:12 +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:
+241
-15
@@ -24,6 +24,12 @@ import {
|
||||
type ReasoningEffort,
|
||||
validatePatternDefinition,
|
||||
} from '@shared/domain/pattern';
|
||||
import {
|
||||
applyDiscoveredMcpServerStatus,
|
||||
normalizeDiscoveredToolingState,
|
||||
type DiscoveredToolingState,
|
||||
type DiscoveredToolingStatus,
|
||||
} from '@shared/domain/discoveredTooling';
|
||||
import {
|
||||
approvalPolicyRequiresCheckpoint,
|
||||
dequeuePendingApprovalState,
|
||||
@@ -71,6 +77,8 @@ import {
|
||||
createSessionToolingSelection,
|
||||
listApprovalToolNames,
|
||||
normalizeTheme,
|
||||
resolveProjectToolingSettings,
|
||||
resolveWorkspaceToolingSettings,
|
||||
type AppearanceTheme,
|
||||
type LspProfileDefinition,
|
||||
type McpServerDefinition,
|
||||
@@ -86,7 +94,11 @@ import { mergeStreamingText } from '@shared/utils/streamingText';
|
||||
|
||||
import { WorkspaceRepository } from '@main/persistence/workspaceRepository';
|
||||
import { SecretStore } from '@main/secrets/secretStore';
|
||||
import { SidecarClient } from '@main/sidecar/sidecarProcess';
|
||||
import { ConfigScannerRegistry } from '@main/services/configScanner';
|
||||
import {
|
||||
SIDECAR_STOPPED_BEFORE_COMPLETION_MESSAGE,
|
||||
SidecarClient,
|
||||
} from '@main/sidecar/sidecarProcess';
|
||||
import { GitService } from '@main/git/gitService';
|
||||
import {
|
||||
buildRunTurnToolingConfig as buildSessionToolingConfig,
|
||||
@@ -104,6 +116,8 @@ type PendingApprovalHandle = {
|
||||
resolve: (decision: ApprovalDecision) => void | Promise<void>;
|
||||
};
|
||||
|
||||
type DiscoveredToolingResolution = 'accept' | 'dismiss';
|
||||
|
||||
function isBuiltinPattern(patternId: string): boolean {
|
||||
return patternId.startsWith('pattern-');
|
||||
}
|
||||
@@ -118,14 +132,20 @@ function equalStringArrays(left?: readonly string[], right?: readonly string[]):
|
||||
return normalizedLeft.every((value, index) => value === normalizedRight[index]);
|
||||
}
|
||||
|
||||
function isSidecarStoppedBeforeCompletionError(error: unknown): error is Error {
|
||||
return error instanceof Error && error.message === SIDECAR_STOPPED_BEFORE_COMPLETION_MESSAGE;
|
||||
}
|
||||
|
||||
export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
private readonly workspaceRepository = new WorkspaceRepository();
|
||||
private readonly sidecar = new SidecarClient();
|
||||
private readonly secretStore = new SecretStore();
|
||||
private readonly gitService = new GitService();
|
||||
private readonly configScanner = new ConfigScannerRegistry();
|
||||
private readonly pendingApprovalHandles = new Map<string, PendingApprovalHandle>();
|
||||
private workspace?: WorkspaceState;
|
||||
private sidecarCapabilities?: SidecarCapabilities;
|
||||
private sidecarCapabilitiesPromise?: Promise<SidecarCapabilities>;
|
||||
private didScheduleInitialProjectGitRefresh = false;
|
||||
|
||||
async describeSidecarCapabilities(): Promise<SidecarCapabilities> {
|
||||
@@ -139,8 +159,23 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
async loadWorkspace(): Promise<WorkspaceState> {
|
||||
if (!this.workspace) {
|
||||
this.workspace = await this.workspaceRepository.load();
|
||||
const selectedProjectId = this.workspace.selectedProjectId;
|
||||
const selectedProject = selectedProjectId
|
||||
? this.workspace.projects.find((project) => project.id === selectedProjectId)
|
||||
: undefined;
|
||||
const didSyncUserTooling = await this.syncUserDiscoveredTooling(this.workspace);
|
||||
const didSyncProjectTooling = selectedProject
|
||||
? await this.syncProjectDiscoveredTooling(this.workspace, selectedProject)
|
||||
: false;
|
||||
const didPruneSelections = this.pruneUnavailableSessionToolingSelections(this.workspace);
|
||||
const didPruneApprovalTools = await this.pruneUnavailableApprovalTools(this.workspace);
|
||||
if (didPruneApprovalTools || this.failInterruptedPendingApprovals(this.workspace)) {
|
||||
if (
|
||||
didSyncUserTooling
|
||||
|| didSyncProjectTooling
|
||||
|| didPruneSelections
|
||||
|| didPruneApprovalTools
|
||||
|| this.failInterruptedPendingApprovals(this.workspace)
|
||||
) {
|
||||
await this.workspaceRepository.save(this.workspace);
|
||||
}
|
||||
}
|
||||
@@ -177,6 +212,7 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
|
||||
this.workspace = undefined;
|
||||
this.sidecarCapabilities = undefined;
|
||||
this.sidecarCapabilitiesPromise = undefined;
|
||||
this.didScheduleInitialProjectGitRefresh = false;
|
||||
|
||||
return this.loadWorkspace();
|
||||
@@ -197,6 +233,11 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
const existing = workspace.projects.find((project) => project.path === folderPath);
|
||||
if (existing) {
|
||||
workspace.selectedProjectId = existing.id;
|
||||
const didSyncProjectTooling = await this.syncProjectDiscoveredTooling(workspace, existing);
|
||||
if (didSyncProjectTooling) {
|
||||
this.pruneUnavailableSessionToolingSelections(workspace);
|
||||
await this.pruneUnavailableApprovalTools(workspace);
|
||||
}
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
@@ -210,6 +251,7 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
|
||||
workspace.projects.push(project);
|
||||
workspace.selectedProjectId = project.id;
|
||||
await this.syncProjectDiscoveredTooling(workspace, project);
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
@@ -236,6 +278,49 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async resolveWorkspaceDiscoveredTooling(
|
||||
serverIds: string[],
|
||||
resolution: DiscoveredToolingResolution,
|
||||
): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
workspace.settings.discoveredUserTooling = applyDiscoveredMcpServerStatus(
|
||||
workspace.settings.discoveredUserTooling,
|
||||
serverIds,
|
||||
this.resolveDiscoveredToolingStatus(resolution),
|
||||
);
|
||||
|
||||
this.pruneUnavailableSessionToolingSelections(workspace);
|
||||
await this.pruneUnavailableApprovalTools(workspace);
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async rescanProjectConfigs(projectId: string): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const project = this.requireProject(workspace, projectId);
|
||||
await this.syncProjectDiscoveredTooling(workspace, project);
|
||||
this.pruneUnavailableSessionToolingSelections(workspace);
|
||||
await this.pruneUnavailableApprovalTools(workspace);
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async resolveProjectDiscoveredTooling(
|
||||
projectId: string,
|
||||
serverIds: string[],
|
||||
resolution: DiscoveredToolingResolution,
|
||||
): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const project = this.requireProject(workspace, projectId);
|
||||
project.discoveredTooling = applyDiscoveredMcpServerStatus(
|
||||
project.discoveredTooling,
|
||||
serverIds,
|
||||
this.resolveDiscoveredToolingStatus(resolution),
|
||||
);
|
||||
|
||||
this.pruneUnavailableSessionToolingSelections(workspace);
|
||||
await this.pruneUnavailableApprovalTools(workspace);
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async savePattern(pattern: PatternDefinition): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const knownApprovalToolNames = await this.listKnownApprovalToolNames(workspace);
|
||||
@@ -675,7 +760,7 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const session = this.requireSession(workspace, sessionId);
|
||||
this.requireProject(workspace, session.projectId);
|
||||
const project = this.requireProject(workspace, session.projectId);
|
||||
|
||||
if (session.status === 'running') {
|
||||
throw new Error('Wait for the current response to finish before changing session tools.');
|
||||
@@ -685,7 +770,10 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
enabledMcpServerIds,
|
||||
enabledLspProfileIds,
|
||||
});
|
||||
validateSessionToolingSelectionIds(workspace.settings.tooling, selection);
|
||||
validateSessionToolingSelectionIds(
|
||||
resolveProjectToolingSettings(workspace.settings, project.discoveredTooling),
|
||||
selection,
|
||||
);
|
||||
|
||||
session.tooling = selection;
|
||||
session.updatedAt = nowIso();
|
||||
@@ -698,7 +786,7 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const session = this.requireSession(workspace, sessionId);
|
||||
this.requireProject(workspace, session.projectId);
|
||||
const project = this.requireProject(workspace, session.projectId);
|
||||
|
||||
if (session.status === 'running') {
|
||||
throw new Error('Wait for the current response to finish before changing session approval settings.');
|
||||
@@ -708,7 +796,7 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
autoApprovedToolNames === undefined ? undefined : { autoApprovedToolNames },
|
||||
);
|
||||
|
||||
const knownToolNames = new Set(await this.listKnownApprovalToolNames(workspace));
|
||||
const knownToolNames = new Set(await this.listKnownApprovalToolNames(workspace, project));
|
||||
const unknownToolName = settings?.autoApprovedToolNames.find((toolName) => !knownToolNames.has(toolName));
|
||||
if (unknownToolName) {
|
||||
throw new Error(`Unknown approval tool "${unknownToolName}".`);
|
||||
@@ -741,6 +829,15 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
|
||||
async selectProject(projectId?: string): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
if (projectId) {
|
||||
const project = this.requireProject(workspace, projectId);
|
||||
const didSyncProjectTooling = await this.syncProjectDiscoveredTooling(workspace, project);
|
||||
if (didSyncProjectTooling) {
|
||||
this.pruneUnavailableSessionToolingSelections(workspace);
|
||||
await this.pruneUnavailableApprovalTools(workspace);
|
||||
}
|
||||
}
|
||||
|
||||
workspace.selectedProjectId = projectId;
|
||||
workspace.selectedSessionId = workspace.selectedSessionId;
|
||||
return this.persistAndBroadcast(workspace);
|
||||
@@ -1177,18 +1274,29 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
return normalizePatternModels(patternWithApprovalSettings, modelCatalog);
|
||||
}
|
||||
|
||||
private async listKnownApprovalToolNames(workspace: WorkspaceState): Promise<string[]> {
|
||||
private async listKnownApprovalToolNames(
|
||||
workspace: WorkspaceState,
|
||||
project?: ProjectRecord,
|
||||
): Promise<string[]> {
|
||||
const capabilities = await this.loadSidecarCapabilities();
|
||||
const runtimeTools = capabilities.runtimeTools.length > 0 ? capabilities.runtimeTools : undefined;
|
||||
return listApprovalToolNames(workspace.settings.tooling, runtimeTools);
|
||||
const tooling = project
|
||||
? resolveProjectToolingSettings(workspace.settings, project.discoveredTooling)
|
||||
: resolveWorkspaceToolingSettings(workspace.settings);
|
||||
return listApprovalToolNames(tooling, runtimeTools);
|
||||
}
|
||||
|
||||
private async pruneUnavailableApprovalTools(workspace: WorkspaceState): Promise<boolean> {
|
||||
const knownToolNames = await this.listKnownApprovalToolNames(workspace);
|
||||
const capabilities = await this.loadSidecarCapabilities();
|
||||
const runtimeTools = capabilities.runtimeTools.length > 0 ? capabilities.runtimeTools : undefined;
|
||||
const workspaceKnownToolNames = listApprovalToolNames(
|
||||
resolveWorkspaceToolingSettings(workspace.settings),
|
||||
runtimeTools,
|
||||
);
|
||||
let changed = false;
|
||||
|
||||
for (const pattern of workspace.patterns) {
|
||||
const nextPolicy = pruneApprovalPolicyTools(pattern.approvalPolicy, knownToolNames);
|
||||
const nextPolicy = pruneApprovalPolicyTools(pattern.approvalPolicy, workspaceKnownToolNames);
|
||||
if (!equalStringArrays(
|
||||
pattern.approvalPolicy?.autoApprovedToolNames,
|
||||
nextPolicy?.autoApprovedToolNames,
|
||||
@@ -1199,6 +1307,11 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
}
|
||||
|
||||
for (const session of workspace.sessions) {
|
||||
const project = this.requireProject(workspace, session.projectId);
|
||||
const knownToolNames = listApprovalToolNames(
|
||||
resolveProjectToolingSettings(workspace.settings, project.discoveredTooling),
|
||||
runtimeTools,
|
||||
);
|
||||
const nextSettings = pruneSessionApprovalSettings(
|
||||
session.approvalSettings,
|
||||
knownToolNames,
|
||||
@@ -1219,9 +1332,89 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
workspace: WorkspaceState,
|
||||
session: SessionRecord,
|
||||
): RunTurnToolingConfig | undefined {
|
||||
const project = this.requireProject(workspace, session.projectId);
|
||||
const tooling = resolveProjectToolingSettings(workspace.settings, project.discoveredTooling);
|
||||
const selection = resolveSessionToolingSelection(session);
|
||||
validateSessionToolingSelectionIds(workspace.settings.tooling, selection);
|
||||
return buildSessionToolingConfig(workspace.settings.tooling, selection);
|
||||
validateSessionToolingSelectionIds(tooling, selection);
|
||||
return buildSessionToolingConfig(tooling, selection);
|
||||
}
|
||||
|
||||
private async syncUserDiscoveredTooling(workspace: WorkspaceState): Promise<boolean> {
|
||||
const nextState = await this.configScanner.scanUser(workspace.settings.discoveredUserTooling);
|
||||
if (this.equalDiscoveredToolingState(workspace.settings.discoveredUserTooling, nextState)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
workspace.settings.discoveredUserTooling = nextState;
|
||||
return true;
|
||||
}
|
||||
|
||||
private async syncProjectDiscoveredTooling(
|
||||
workspace: WorkspaceState,
|
||||
project: ProjectRecord,
|
||||
): Promise<boolean> {
|
||||
if (isScratchpadProject(project)) {
|
||||
if (!project.discoveredTooling || this.equalDiscoveredToolingState(project.discoveredTooling, undefined)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
project.discoveredTooling = undefined;
|
||||
return true;
|
||||
}
|
||||
|
||||
const nextState = await this.configScanner.scanProject(
|
||||
project.id,
|
||||
project.path,
|
||||
project.discoveredTooling,
|
||||
);
|
||||
if (this.equalDiscoveredToolingState(project.discoveredTooling, nextState)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
project.discoveredTooling = nextState;
|
||||
return true;
|
||||
}
|
||||
|
||||
private pruneUnavailableSessionToolingSelections(workspace: WorkspaceState): boolean {
|
||||
let changed = false;
|
||||
|
||||
for (const session of workspace.sessions) {
|
||||
const project = this.requireProject(workspace, session.projectId);
|
||||
const effectiveTooling = resolveProjectToolingSettings(workspace.settings, project.discoveredTooling);
|
||||
const knownMcpServerIds = new Set(effectiveTooling.mcpServers.map((server) => server.id));
|
||||
const knownLspProfileIds = new Set(effectiveTooling.lspProfiles.map((profile) => profile.id));
|
||||
const selection = resolveSessionToolingSelection(session);
|
||||
const nextSelection = normalizeSessionToolingSelection({
|
||||
enabledMcpServerIds: selection.enabledMcpServerIds.filter((id) => knownMcpServerIds.has(id)),
|
||||
enabledLspProfileIds: selection.enabledLspProfileIds.filter((id) => knownLspProfileIds.has(id)),
|
||||
});
|
||||
|
||||
if (
|
||||
equalStringArrays(selection.enabledMcpServerIds, nextSelection.enabledMcpServerIds)
|
||||
&& equalStringArrays(selection.enabledLspProfileIds, nextSelection.enabledLspProfileIds)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
session.tooling = nextSelection;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
private resolveDiscoveredToolingStatus(
|
||||
resolution: DiscoveredToolingResolution,
|
||||
): Exclude<DiscoveredToolingStatus, 'pending'> {
|
||||
return resolution === 'accept' ? 'accepted' : 'dismissed';
|
||||
}
|
||||
|
||||
private equalDiscoveredToolingState(
|
||||
left?: DiscoveredToolingState,
|
||||
right?: DiscoveredToolingState,
|
||||
): boolean {
|
||||
return JSON.stringify(normalizeDiscoveredToolingState(left).mcpServers)
|
||||
=== JSON.stringify(normalizeDiscoveredToolingState(right).mcpServers);
|
||||
}
|
||||
|
||||
private updateSessionRun(
|
||||
@@ -1300,10 +1493,43 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
}
|
||||
|
||||
private async loadSidecarCapabilities(forceRefresh = false): Promise<SidecarCapabilities> {
|
||||
if (forceRefresh || !this.sidecarCapabilities) {
|
||||
this.sidecarCapabilities = await this.sidecar.describeCapabilities();
|
||||
if (forceRefresh) {
|
||||
this.sidecarCapabilities = undefined;
|
||||
this.sidecarCapabilitiesPromise = undefined;
|
||||
}
|
||||
|
||||
return this.sidecarCapabilities;
|
||||
if (this.sidecarCapabilities) {
|
||||
return this.sidecarCapabilities;
|
||||
}
|
||||
|
||||
if (!this.sidecarCapabilitiesPromise) {
|
||||
let request!: Promise<SidecarCapabilities>;
|
||||
request = (async () => {
|
||||
try {
|
||||
const capabilities = await this.fetchSidecarCapabilities();
|
||||
this.sidecarCapabilities = capabilities;
|
||||
return capabilities;
|
||||
} finally {
|
||||
if (this.sidecarCapabilitiesPromise === request) {
|
||||
this.sidecarCapabilitiesPromise = undefined;
|
||||
}
|
||||
}
|
||||
})();
|
||||
this.sidecarCapabilitiesPromise = request;
|
||||
}
|
||||
|
||||
return this.sidecarCapabilitiesPromise;
|
||||
}
|
||||
|
||||
private async fetchSidecarCapabilities(): Promise<SidecarCapabilities> {
|
||||
try {
|
||||
return await this.sidecar.describeCapabilities();
|
||||
} catch (error) {
|
||||
if (!isSidecarStoppedBeforeCompletionError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return this.sidecar.describeCapabilities();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,11 @@ import { BrowserWindow, ipcMain } from 'electron';
|
||||
import { ipcChannels } from '@shared/contracts/channels';
|
||||
import type {
|
||||
CreateSessionInput,
|
||||
ResolveProjectDiscoveredToolingInput,
|
||||
ResolveWorkspaceDiscoveredToolingInput,
|
||||
DuplicateSessionInput,
|
||||
RenameSessionInput,
|
||||
RescanProjectConfigsInput,
|
||||
ResolveSessionApprovalInput,
|
||||
SaveLspProfileInput,
|
||||
SaveMcpServerInput,
|
||||
@@ -29,9 +32,22 @@ export function registerIpcHandlers(window: BrowserWindow, service: EryxAppServi
|
||||
ipcMain.handle(ipcChannels.loadWorkspace, () => service.loadWorkspace());
|
||||
ipcMain.handle(ipcChannels.addProject, () => service.addProject());
|
||||
ipcMain.handle(ipcChannels.removeProject, (_event, projectId: string) => service.removeProject(projectId));
|
||||
ipcMain.handle(
|
||||
ipcChannels.resolveWorkspaceDiscoveredTooling,
|
||||
(_event, input: ResolveWorkspaceDiscoveredToolingInput) =>
|
||||
service.resolveWorkspaceDiscoveredTooling(input.serverIds, input.resolution),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.refreshProjectGitContext, (_event, projectId?: string) =>
|
||||
service.refreshProjectGitContext(projectId),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.rescanProjectConfigs, (_event, input: RescanProjectConfigsInput) =>
|
||||
service.rescanProjectConfigs(input.projectId),
|
||||
);
|
||||
ipcMain.handle(
|
||||
ipcChannels.resolveProjectDiscoveredTooling,
|
||||
(_event, input: ResolveProjectDiscoveredToolingInput) =>
|
||||
service.resolveProjectDiscoveredTooling(input.projectId, input.serverIds, input.resolution),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.savePattern, (_event, input: SavePatternInput) => service.savePattern(input.pattern));
|
||||
ipcMain.handle(ipcChannels.deletePattern, (_event, patternId: string) => service.deletePattern(patternId));
|
||||
ipcMain.handle(ipcChannels.setPatternFavorite, (_event, input: SetPatternFavoriteInput) =>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { mkdir } from 'node:fs/promises';
|
||||
import { createBuiltinPatterns, resolvePatternGraph } from '@shared/domain/pattern';
|
||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||
import { mergeScratchpadProject } from '@shared/domain/project';
|
||||
import { normalizeDiscoveredToolingState } from '@shared/domain/discoveredTooling';
|
||||
import { normalizeSessionRunRecords } from '@shared/domain/runTimeline';
|
||||
import {
|
||||
normalizeSessionToolingSelection,
|
||||
@@ -63,7 +64,13 @@ export class WorkspaceRepository {
|
||||
return seeded;
|
||||
}
|
||||
|
||||
const projects = mergeScratchpadProject(stored.projects ?? [], this.scratchpadPath);
|
||||
const projects = mergeScratchpadProject(
|
||||
(stored.projects ?? []).map((project) => ({
|
||||
...project,
|
||||
discoveredTooling: normalizeDiscoveredToolingState(project.discoveredTooling),
|
||||
})),
|
||||
this.scratchpadPath,
|
||||
);
|
||||
const settings = normalizeWorkspaceSettings(stored.settings);
|
||||
|
||||
const workspace: WorkspaceState = {
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import {
|
||||
buildDiscoveredMcpServerFingerprint,
|
||||
buildDiscoveredMcpServerId,
|
||||
mergeDiscoveredToolingState,
|
||||
normalizeDiscoveredToolingState,
|
||||
type DiscoveredMcpServer,
|
||||
type DiscoveredToolingScope,
|
||||
type DiscoveredToolingState,
|
||||
} from '@shared/domain/discoveredTooling';
|
||||
import { nowIso } from '@shared/utils/ids';
|
||||
|
||||
export interface ProjectScanContext {
|
||||
scope: 'project';
|
||||
projectId: string;
|
||||
projectPath: string;
|
||||
}
|
||||
|
||||
export interface UserScanContext {
|
||||
scope: 'user';
|
||||
homePath: string;
|
||||
}
|
||||
|
||||
export type ScanContext = ProjectScanContext | UserScanContext;
|
||||
|
||||
type ConfigScannerResult =
|
||||
| { kind: 'success'; mcpServers: DiscoveredMcpServer[] }
|
||||
| { kind: 'retain-previous' };
|
||||
|
||||
export interface ConfigScanner {
|
||||
readonly id: string;
|
||||
readonly scope: DiscoveredToolingScope;
|
||||
scan(context: ScanContext): Promise<ConfigScannerResult>;
|
||||
}
|
||||
|
||||
export class ConfigScannerRegistry {
|
||||
constructor(
|
||||
private readonly scanners: ReadonlyArray<ConfigScanner> = defaultConfigScanners,
|
||||
) {}
|
||||
|
||||
async scanProject(
|
||||
projectId: string,
|
||||
projectPath: string,
|
||||
current?: DiscoveredToolingState,
|
||||
): Promise<DiscoveredToolingState> {
|
||||
return this.scanAll(
|
||||
{
|
||||
scope: 'project',
|
||||
projectId,
|
||||
projectPath,
|
||||
},
|
||||
current,
|
||||
);
|
||||
}
|
||||
|
||||
async scanUser(current?: DiscoveredToolingState, homePath = homedir()): Promise<DiscoveredToolingState> {
|
||||
return this.scanAll(
|
||||
{
|
||||
scope: 'user',
|
||||
homePath,
|
||||
},
|
||||
current,
|
||||
);
|
||||
}
|
||||
|
||||
private async scanAll(context: ScanContext, current?: DiscoveredToolingState): Promise<DiscoveredToolingState> {
|
||||
const previous = normalizeDiscoveredToolingState(current);
|
||||
const previousByScanner = new Map<string, DiscoveredMcpServer[]>();
|
||||
|
||||
for (const server of previous.mcpServers) {
|
||||
const entries = previousByScanner.get(server.scannerId) ?? [];
|
||||
entries.push(server);
|
||||
previousByScanner.set(server.scannerId, entries);
|
||||
}
|
||||
|
||||
const scannedServers: DiscoveredMcpServer[] = [];
|
||||
for (const scanner of this.scanners) {
|
||||
if (scanner.scope !== context.scope) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = await scanner.scan(context);
|
||||
if (result.kind === 'retain-previous') {
|
||||
scannedServers.push(...(previousByScanner.get(scanner.id) ?? []));
|
||||
continue;
|
||||
}
|
||||
|
||||
scannedServers.push(...result.mcpServers);
|
||||
}
|
||||
|
||||
return mergeDiscoveredToolingState(previous, scannedServers, nowIso());
|
||||
}
|
||||
}
|
||||
|
||||
const defaultConfigScanners: ReadonlyArray<ConfigScanner> = [
|
||||
createProjectJsonMcpScanner({
|
||||
id: 'vscode-mcp',
|
||||
resolvePath: (context) => join(context.projectPath, '.vscode', 'mcp.json'),
|
||||
sourceLabel: '.vscode\\mcp.json',
|
||||
rootKey: 'servers',
|
||||
}),
|
||||
createProjectJsonMcpScanner({
|
||||
id: 'claude-code-mcp',
|
||||
resolvePath: (context) => join(context.projectPath, '.mcp.json'),
|
||||
sourceLabel: '.mcp.json',
|
||||
rootKey: 'mcpServers',
|
||||
}),
|
||||
createProjectJsonMcpScanner({
|
||||
id: 'copilot-project-mcp',
|
||||
resolvePath: (context) => join(context.projectPath, '.copilot', 'mcp.json'),
|
||||
sourceLabel: '.copilot\\mcp.json',
|
||||
rootKey: 'mcpServers',
|
||||
}),
|
||||
createUserJsonMcpScanner({
|
||||
id: 'copilot-user-mcp',
|
||||
resolvePath: (context) => join(context.homePath, '.copilot', 'mcp.json'),
|
||||
sourceLabel: '~\\.copilot\\mcp.json',
|
||||
rootKey: 'mcpServers',
|
||||
}),
|
||||
];
|
||||
|
||||
function createProjectJsonMcpScanner(options: {
|
||||
id: string;
|
||||
resolvePath: (context: ProjectScanContext) => string;
|
||||
sourceLabel: string;
|
||||
rootKey: 'servers' | 'mcpServers';
|
||||
}): ConfigScanner {
|
||||
return createJsonMcpScanner('project', options);
|
||||
}
|
||||
|
||||
function createUserJsonMcpScanner(options: {
|
||||
id: string;
|
||||
resolvePath: (context: UserScanContext) => string;
|
||||
sourceLabel: string;
|
||||
rootKey: 'servers' | 'mcpServers';
|
||||
}): ConfigScanner {
|
||||
return createJsonMcpScanner('user', options);
|
||||
}
|
||||
|
||||
function createJsonMcpScanner<TContext extends ScanContext>(scope: DiscoveredToolingScope, options: {
|
||||
id: string;
|
||||
resolvePath: (context: TContext) => string;
|
||||
sourceLabel: string;
|
||||
rootKey: 'servers' | 'mcpServers';
|
||||
}): ConfigScanner {
|
||||
return {
|
||||
id: options.id,
|
||||
scope,
|
||||
async scan(context): Promise<ConfigScannerResult> {
|
||||
if (context.scope !== scope) {
|
||||
return { kind: 'success', mcpServers: [] };
|
||||
}
|
||||
|
||||
const filePath = options.resolvePath(context as TContext);
|
||||
const fileContents = await readJsonConfigFile(filePath, options.sourceLabel);
|
||||
if (fileContents.kind !== 'success') {
|
||||
return fileContents;
|
||||
}
|
||||
|
||||
const rawServers = extractMcpServerEntries(fileContents.value, options.rootKey);
|
||||
if (rawServers.kind !== 'success') {
|
||||
return rawServers;
|
||||
}
|
||||
|
||||
const mcpServers = Object.entries(rawServers.value)
|
||||
.flatMap(([serverName, rawServerConfig]) => {
|
||||
const server = parseDiscoveredMcpServer({
|
||||
scannerId: options.id,
|
||||
sourcePath: filePath,
|
||||
sourceLabel: options.sourceLabel,
|
||||
context,
|
||||
serverName,
|
||||
rawServerConfig,
|
||||
});
|
||||
return server ? [server] : [];
|
||||
});
|
||||
|
||||
return { kind: 'success', mcpServers };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function readJsonConfigFile(
|
||||
filePath: string,
|
||||
sourceLabel: string,
|
||||
): Promise<{ kind: 'success'; value: unknown } | { kind: 'retain-previous' }> {
|
||||
let contents: string;
|
||||
try {
|
||||
contents = await readFile(filePath, 'utf8');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return { kind: 'success', value: undefined };
|
||||
}
|
||||
|
||||
console.warn(`[aryx tooling] Failed to read ${sourceLabel}:`, error);
|
||||
return { kind: 'retain-previous' };
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
kind: 'success',
|
||||
value: JSON.parse(contents) as unknown,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(`[aryx tooling] Failed to parse ${sourceLabel}:`, error);
|
||||
return { kind: 'retain-previous' };
|
||||
}
|
||||
}
|
||||
|
||||
function extractMcpServerEntries(
|
||||
value: unknown,
|
||||
rootKey: 'servers' | 'mcpServers',
|
||||
): { kind: 'success'; value: Record<string, unknown> } | { kind: 'retain-previous' } {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return {
|
||||
kind: 'success',
|
||||
value: {},
|
||||
};
|
||||
}
|
||||
|
||||
const rawServers = (value as Record<string, unknown>)[rootKey];
|
||||
if (rawServers === undefined) {
|
||||
return {
|
||||
kind: 'success',
|
||||
value: {},
|
||||
};
|
||||
}
|
||||
|
||||
if (!rawServers || typeof rawServers !== 'object' || Array.isArray(rawServers)) {
|
||||
console.warn(`[aryx tooling] Expected "${rootKey}" to be an object.`);
|
||||
return { kind: 'retain-previous' };
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'success',
|
||||
value: rawServers as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
function parseDiscoveredMcpServer(options: {
|
||||
scannerId: string;
|
||||
sourcePath: string;
|
||||
sourceLabel: string;
|
||||
context: ScanContext;
|
||||
serverName: string;
|
||||
rawServerConfig: unknown;
|
||||
}): DiscoveredMcpServer | undefined {
|
||||
const serverName = options.serverName.trim();
|
||||
if (!serverName) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!options.rawServerConfig || typeof options.rawServerConfig !== 'object' || Array.isArray(options.rawServerConfig)) {
|
||||
console.warn(`[aryx tooling] Ignoring invalid MCP server "${serverName}" from ${options.sourceLabel}.`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const config = substituteScanVariables(
|
||||
options.rawServerConfig as Record<string, unknown>,
|
||||
options.context,
|
||||
) as Record<string, unknown>;
|
||||
const rawType = normalizeOptionalString(config.type);
|
||||
const command = normalizeOptionalString(config.command);
|
||||
const url = normalizeOptionalString(config.url);
|
||||
const tools = normalizeStringArray(config.tools);
|
||||
const timeoutMs = normalizeOptionalNumber(config.timeoutMs ?? config.timeout);
|
||||
const scopeKey = options.context.scope === 'project' ? options.context.projectId : 'workspace';
|
||||
const id = buildDiscoveredMcpServerId(options.context.scope, scopeKey, options.scannerId, serverName);
|
||||
|
||||
if (rawType === 'http' || rawType === 'sse' || (!rawType && url)) {
|
||||
if (!url) {
|
||||
console.warn(`[aryx tooling] Ignoring MCP server "${serverName}" from ${options.sourceLabel}: missing URL.`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const server: DiscoveredMcpServer = {
|
||||
id,
|
||||
name: serverName,
|
||||
transport: rawType === 'sse' ? 'sse' : 'http',
|
||||
tools,
|
||||
timeoutMs,
|
||||
scope: options.context.scope,
|
||||
scannerId: options.scannerId,
|
||||
sourcePath: options.sourcePath,
|
||||
sourceLabel: options.sourceLabel,
|
||||
url,
|
||||
headers: normalizeStringRecord(config.headers),
|
||||
fingerprint: '',
|
||||
status: 'pending',
|
||||
};
|
||||
|
||||
return {
|
||||
...server,
|
||||
fingerprint: buildDiscoveredMcpServerFingerprint(server),
|
||||
};
|
||||
}
|
||||
|
||||
if (!command) {
|
||||
console.warn(`[aryx tooling] Ignoring MCP server "${serverName}" from ${options.sourceLabel}: missing command.`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const server: DiscoveredMcpServer = {
|
||||
id,
|
||||
name: serverName,
|
||||
transport: 'local',
|
||||
tools,
|
||||
timeoutMs,
|
||||
scope: options.context.scope,
|
||||
scannerId: options.scannerId,
|
||||
sourcePath: options.sourcePath,
|
||||
sourceLabel: options.sourceLabel,
|
||||
command,
|
||||
args: normalizeStringArray(config.args),
|
||||
cwd: normalizeOptionalString(config.cwd),
|
||||
env: normalizeStringRecord(config.env),
|
||||
fingerprint: '',
|
||||
status: 'pending',
|
||||
};
|
||||
|
||||
return {
|
||||
...server,
|
||||
fingerprint: buildDiscoveredMcpServerFingerprint(server),
|
||||
};
|
||||
}
|
||||
|
||||
function substituteScanVariables(value: unknown, context: ScanContext): unknown {
|
||||
if (typeof value === 'string') {
|
||||
return context.scope === 'project'
|
||||
? value.replaceAll('${workspaceFolder}', context.projectPath)
|
||||
: value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => substituteScanVariables(item, context));
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>).map(([key, nestedValue]) => [
|
||||
key,
|
||||
substituteScanVariables(nestedValue, context),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [...new Set(
|
||||
value
|
||||
.map((item) => normalizeOptionalString(item))
|
||||
.filter((item): item is string => item !== undefined),
|
||||
)];
|
||||
}
|
||||
|
||||
function normalizeStringRecord(value: unknown): Record<string, string> | undefined {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const entries = Object.entries(value as Record<string, unknown>)
|
||||
.map(([key, rawValue]) => {
|
||||
const normalizedKey = key.trim();
|
||||
const normalizedValue = normalizeOptionalString(rawValue);
|
||||
return [normalizedKey, normalizedValue] as const;
|
||||
})
|
||||
.filter(([key, normalizedValue]) => key.length > 0 && normalizedValue !== undefined)
|
||||
.sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey));
|
||||
|
||||
if (entries.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return Object.fromEntries(entries) as Record<string, string>;
|
||||
}
|
||||
|
||||
function normalizeOptionalString(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = `${value}`.trim();
|
||||
return normalized ? normalized : undefined;
|
||||
}
|
||||
|
||||
function normalizeOptionalNumber(value: unknown): number | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'string' && value.trim().length > 0) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -55,6 +55,7 @@ export function buildRunTurnToolingConfig(
|
||||
command: server.command,
|
||||
args: [...server.args],
|
||||
cwd: server.cwd,
|
||||
env: server.env ? { ...server.env } : undefined,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -63,12 +64,13 @@ export function buildRunTurnToolingConfig(
|
||||
{
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
transport: server.transport,
|
||||
tools: [...server.tools],
|
||||
timeoutMs: server.timeoutMs,
|
||||
url: server.url,
|
||||
},
|
||||
];
|
||||
transport: server.transport,
|
||||
tools: [...server.tools],
|
||||
timeoutMs: server.timeoutMs,
|
||||
url: server.url,
|
||||
headers: server.headers ? { ...server.headers } : undefined,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const lspProfiles = selection.enabledLspProfileIds.flatMap((id): RunTurnLspProfileConfig[] => {
|
||||
|
||||
Reference in New Issue
Block a user