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

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

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

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

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