mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-06 19:58:43 +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:
@@ -0,0 +1,288 @@
|
||||
export type DiscoveredToolingScope = 'user' | 'project';
|
||||
export type DiscoveredToolingStatus = 'pending' | 'accepted' | 'dismissed';
|
||||
export type DiscoveredMcpServerTransport = 'local' | 'http' | 'sse';
|
||||
|
||||
export interface BaseDiscoveredMcpServer {
|
||||
id: string;
|
||||
name: string;
|
||||
transport: DiscoveredMcpServerTransport;
|
||||
tools: string[];
|
||||
timeoutMs?: number;
|
||||
scope: DiscoveredToolingScope;
|
||||
scannerId: string;
|
||||
sourcePath: string;
|
||||
sourceLabel: string;
|
||||
fingerprint: string;
|
||||
status: DiscoveredToolingStatus;
|
||||
}
|
||||
|
||||
export interface DiscoveredLocalMcpServer extends BaseDiscoveredMcpServer {
|
||||
transport: 'local';
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface DiscoveredRemoteMcpServer extends BaseDiscoveredMcpServer {
|
||||
transport: 'http' | 'sse';
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export type DiscoveredMcpServer = DiscoveredLocalMcpServer | DiscoveredRemoteMcpServer;
|
||||
|
||||
export interface DiscoveredToolingState {
|
||||
mcpServers: DiscoveredMcpServer[];
|
||||
lastScannedAt?: string;
|
||||
}
|
||||
|
||||
export type ProjectDiscoveredTooling = DiscoveredToolingState;
|
||||
|
||||
type DiscoveredMcpServerFingerprintInput =
|
||||
| Omit<DiscoveredLocalMcpServer, 'fingerprint' | 'status'>
|
||||
| Omit<DiscoveredRemoteMcpServer, 'fingerprint' | 'status'>
|
||||
| DiscoveredLocalMcpServer
|
||||
| DiscoveredRemoteMcpServer;
|
||||
|
||||
const discoveredStatuses: ReadonlySet<DiscoveredToolingStatus> = new Set(['pending', 'accepted', 'dismissed']);
|
||||
|
||||
export function createDiscoveredToolingState(): DiscoveredToolingState {
|
||||
return {
|
||||
mcpServers: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeDiscoveredToolingState(
|
||||
value?: Partial<DiscoveredToolingState>,
|
||||
): DiscoveredToolingState {
|
||||
return {
|
||||
mcpServers: (value?.mcpServers ?? []).map(normalizeDiscoveredMcpServer).sort(compareDiscoveredMcpServers),
|
||||
lastScannedAt: normalizeOptionalString(value?.lastScannedAt),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeDiscoveredMcpServer(server: DiscoveredMcpServer): DiscoveredMcpServer {
|
||||
const normalizedStatus = discoveredStatuses.has(server.status) ? server.status : 'pending';
|
||||
const normalizedBase = {
|
||||
...server,
|
||||
id: server.id.trim(),
|
||||
name: server.name.trim(),
|
||||
tools: normalizeStringArray(server.tools),
|
||||
scope: server.scope === 'user' ? 'user' : 'project',
|
||||
scannerId: server.scannerId.trim(),
|
||||
sourcePath: server.sourcePath.trim(),
|
||||
sourceLabel: server.sourceLabel.trim(),
|
||||
status: normalizedStatus,
|
||||
} satisfies BaseDiscoveredMcpServer;
|
||||
|
||||
if (server.transport === 'local') {
|
||||
const normalizedServer: DiscoveredLocalMcpServer = {
|
||||
...normalizedBase,
|
||||
transport: 'local',
|
||||
command: server.command.trim(),
|
||||
args: normalizeStringArray(server.args),
|
||||
cwd: normalizeOptionalString(server.cwd),
|
||||
env: normalizeStringRecord(server.env),
|
||||
};
|
||||
|
||||
return {
|
||||
...normalizedServer,
|
||||
fingerprint: normalizeOptionalString(server.fingerprint) ?? buildDiscoveredMcpServerFingerprint(normalizedServer),
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedServer: DiscoveredRemoteMcpServer = {
|
||||
...normalizedBase,
|
||||
transport: server.transport,
|
||||
url: server.url.trim(),
|
||||
headers: normalizeStringRecord(server.headers),
|
||||
};
|
||||
|
||||
return {
|
||||
...normalizedServer,
|
||||
fingerprint: normalizeOptionalString(server.fingerprint) ?? buildDiscoveredMcpServerFingerprint(normalizedServer),
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeDiscoveredToolingState(
|
||||
current: DiscoveredToolingState | undefined,
|
||||
scannedMcpServers: ReadonlyArray<DiscoveredMcpServer>,
|
||||
lastScannedAt: string,
|
||||
): DiscoveredToolingState {
|
||||
const normalizedCurrent = normalizeDiscoveredToolingState(current);
|
||||
const currentById = new Map(normalizedCurrent.mcpServers.map((server) => [server.id, server]));
|
||||
|
||||
const mergedMcpServers = scannedMcpServers
|
||||
.map(normalizeDiscoveredMcpServer)
|
||||
.map((server) => {
|
||||
const existing = currentById.get(server.id);
|
||||
if (existing && existing.fingerprint === server.fingerprint) {
|
||||
return {
|
||||
...server,
|
||||
status: existing.status,
|
||||
} satisfies DiscoveredMcpServer;
|
||||
}
|
||||
|
||||
return {
|
||||
...server,
|
||||
status: 'pending',
|
||||
} satisfies DiscoveredMcpServer;
|
||||
})
|
||||
.sort(compareDiscoveredMcpServers);
|
||||
|
||||
return {
|
||||
mcpServers: mergedMcpServers,
|
||||
lastScannedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyDiscoveredMcpServerStatus(
|
||||
state: DiscoveredToolingState | undefined,
|
||||
serverIds: ReadonlyArray<string>,
|
||||
status: Exclude<DiscoveredToolingStatus, 'pending'>,
|
||||
): DiscoveredToolingState {
|
||||
const normalizedState = normalizeDiscoveredToolingState(state);
|
||||
const serverIdSet = new Set(normalizeStringArray(serverIds));
|
||||
|
||||
return {
|
||||
...normalizedState,
|
||||
mcpServers: normalizedState.mcpServers.map((server) =>
|
||||
serverIdSet.has(server.id)
|
||||
? {
|
||||
...server,
|
||||
status,
|
||||
}
|
||||
: server),
|
||||
};
|
||||
}
|
||||
|
||||
export function listAcceptedDiscoveredMcpServers(
|
||||
state?: Partial<DiscoveredToolingState>,
|
||||
): DiscoveredMcpServer[] {
|
||||
return normalizeDiscoveredToolingState(state).mcpServers.filter((server) => server.status === 'accepted');
|
||||
}
|
||||
|
||||
export function listPendingDiscoveredMcpServers(
|
||||
state?: Partial<DiscoveredToolingState>,
|
||||
): DiscoveredMcpServer[] {
|
||||
return normalizeDiscoveredToolingState(state).mcpServers.filter((server) => server.status === 'pending');
|
||||
}
|
||||
|
||||
export function buildDiscoveredMcpServerId(
|
||||
scope: DiscoveredToolingScope,
|
||||
scopeKey: string,
|
||||
scannerId: string,
|
||||
serverName: string,
|
||||
): string {
|
||||
const normalizedScope = normalizeIdentifierSegment(scope);
|
||||
const normalizedScopeKey = normalizeIdentifierSegment(scopeKey);
|
||||
const normalizedScanner = normalizeIdentifierSegment(scannerId);
|
||||
const normalizedName = normalizeIdentifierSegment(serverName);
|
||||
return `discovered_${normalizedScope}_${normalizedScopeKey}_${normalizedScanner}_${normalizedName}`;
|
||||
}
|
||||
|
||||
export function buildDiscoveredMcpServerFingerprint(
|
||||
server: DiscoveredMcpServerFingerprintInput,
|
||||
): string {
|
||||
const normalizedBase = {
|
||||
id: server.id.trim(),
|
||||
name: server.name.trim(),
|
||||
transport: server.transport,
|
||||
tools: normalizeStringArray(server.tools),
|
||||
timeoutMs: server.timeoutMs,
|
||||
scope: server.scope === 'user' ? 'user' : 'project',
|
||||
scannerId: server.scannerId.trim(),
|
||||
sourcePath: server.sourcePath.trim(),
|
||||
};
|
||||
|
||||
const serialized = server.transport === 'local'
|
||||
? stableSerialize({
|
||||
...normalizedBase,
|
||||
command: server.command.trim(),
|
||||
args: normalizeStringArray(server.args),
|
||||
cwd: normalizeOptionalString(server.cwd),
|
||||
env: normalizeStringRecord(server.env),
|
||||
})
|
||||
: stableSerialize({
|
||||
...normalizedBase,
|
||||
url: server.url.trim(),
|
||||
headers: normalizeStringRecord(server.headers),
|
||||
});
|
||||
|
||||
return hashString(serialized);
|
||||
}
|
||||
|
||||
function compareDiscoveredMcpServers(left: DiscoveredMcpServer, right: DiscoveredMcpServer): number {
|
||||
return (
|
||||
left.scope.localeCompare(right.scope)
|
||||
|| left.sourceLabel.localeCompare(right.sourceLabel)
|
||||
|| left.name.localeCompare(right.name)
|
||||
|| left.id.localeCompare(right.id)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeStringArray(values?: ReadonlyArray<string>): string[] {
|
||||
if (!values) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [...new Set(values.map((value) => value.trim()).filter((value) => value.length > 0))];
|
||||
}
|
||||
|
||||
function normalizeStringRecord(record?: Record<string, string>): Record<string, string> | undefined {
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalizedEntries = Object.entries(record)
|
||||
.map(([key, value]) => [key.trim(), value.trim()] as const)
|
||||
.filter(([key, value]) => key.length > 0 && value.length > 0)
|
||||
.sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey));
|
||||
|
||||
if (normalizedEntries.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return Object.fromEntries(normalizedEntries);
|
||||
}
|
||||
|
||||
function normalizeOptionalString(value?: string): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function normalizeIdentifierSegment(value: string): string {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '');
|
||||
|
||||
return normalized || 'default';
|
||||
}
|
||||
|
||||
function stableSerialize(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map(stableSerialize).join(',')}]`;
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
const entries = Object.entries(value as Record<string, unknown>)
|
||||
.filter(([, nestedValue]) => nestedValue !== undefined)
|
||||
.sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey));
|
||||
return `{${entries.map(([key, nestedValue]) => `${JSON.stringify(key)}:${stableSerialize(nestedValue)}`).join(',')}}`;
|
||||
}
|
||||
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function hashString(value: string): string {
|
||||
let hash = 2166136261;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash ^= value.charCodeAt(index);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
|
||||
return `fnv1a_${(hash >>> 0).toString(16).padStart(8, '0')}`;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { nowIso } from '@shared/utils/ids';
|
||||
import type { ProjectDiscoveredTooling } from '@shared/domain/discoveredTooling';
|
||||
|
||||
export type ProjectGitContextStatus = 'ready' | 'not-repository' | 'git-missing' | 'error';
|
||||
|
||||
@@ -37,6 +38,7 @@ export interface ProjectRecord {
|
||||
path: string;
|
||||
addedAt: string;
|
||||
git?: ProjectGitContext;
|
||||
discoveredTooling?: ProjectDiscoveredTooling;
|
||||
}
|
||||
|
||||
export const SCRATCHPAD_PROJECT_ID = 'project-scratchpad';
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
import {
|
||||
createDiscoveredToolingState,
|
||||
listAcceptedDiscoveredMcpServers,
|
||||
normalizeDiscoveredToolingState,
|
||||
type DiscoveredToolingState,
|
||||
type ProjectDiscoveredTooling,
|
||||
} from '@shared/domain/discoveredTooling';
|
||||
import { nowIso } from '@shared/utils/ids';
|
||||
|
||||
export type McpServerTransport = 'local' | 'http' | 'sse';
|
||||
|
||||
export interface BaseMcpServerDefinition {
|
||||
@@ -15,11 +24,13 @@ export interface LocalMcpServerDefinition extends BaseMcpServerDefinition {
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface RemoteMcpServerDefinition extends BaseMcpServerDefinition {
|
||||
transport: 'http' | 'sse';
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export type McpServerDefinition = LocalMcpServerDefinition | RemoteMcpServerDefinition;
|
||||
@@ -45,6 +56,7 @@ export type AppearanceTheme = 'dark' | 'light' | 'system';
|
||||
export interface WorkspaceSettings {
|
||||
theme: AppearanceTheme;
|
||||
tooling: WorkspaceToolingSettings;
|
||||
discoveredUserTooling: DiscoveredToolingState;
|
||||
}
|
||||
|
||||
export interface SessionToolingSelection {
|
||||
@@ -95,6 +107,7 @@ export function createWorkspaceSettings(): WorkspaceSettings {
|
||||
mcpServers: [],
|
||||
lspProfiles: [],
|
||||
},
|
||||
discoveredUserTooling: createDiscoveredToolingState(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -118,9 +131,27 @@ export function normalizeWorkspaceSettings(settings?: Partial<WorkspaceSettings>
|
||||
mcpServers: (settings?.tooling?.mcpServers ?? []).map(normalizeMcpServerDefinition),
|
||||
lspProfiles: (settings?.tooling?.lspProfiles ?? []).map(normalizeLspProfileDefinition),
|
||||
},
|
||||
discoveredUserTooling: normalizeDiscoveredToolingState(settings?.discoveredUserTooling),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveWorkspaceToolingSettings(settings: WorkspaceSettings): WorkspaceToolingSettings {
|
||||
return mergeAcceptedDiscoveredMcpServers(
|
||||
{
|
||||
mcpServers: settings.tooling.mcpServers.map((server) => ({ ...server })),
|
||||
lspProfiles: settings.tooling.lspProfiles.map((profile) => ({ ...profile })),
|
||||
},
|
||||
settings.discoveredUserTooling,
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveProjectToolingSettings(
|
||||
settings: WorkspaceSettings,
|
||||
projectDiscoveredTooling?: ProjectDiscoveredTooling,
|
||||
): WorkspaceToolingSettings {
|
||||
return mergeAcceptedDiscoveredMcpServers(resolveWorkspaceToolingSettings(settings), projectDiscoveredTooling);
|
||||
}
|
||||
|
||||
export function normalizeSessionToolingSelection(
|
||||
selection?: Partial<SessionToolingSelection>,
|
||||
): SessionToolingSelection {
|
||||
@@ -249,6 +280,7 @@ export function normalizeMcpServerDefinition(server: McpServerDefinition): McpSe
|
||||
command: server.command.trim(),
|
||||
args: normalizeStringArray(server.args),
|
||||
cwd: server.cwd?.trim() || undefined,
|
||||
env: normalizeStringRecord(server.env),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -256,6 +288,7 @@ export function normalizeMcpServerDefinition(server: McpServerDefinition): McpSe
|
||||
...base,
|
||||
transport: server.transport,
|
||||
url: server.url.trim(),
|
||||
headers: normalizeStringRecord(server.headers),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -274,6 +307,57 @@ function normalizeFileExtensions(fileExtensions: string[]): string[] {
|
||||
return normalizeStringArray(fileExtensions).map((value) => (value.startsWith('.') ? value : `.${value}`));
|
||||
}
|
||||
|
||||
function mergeAcceptedDiscoveredMcpServers(
|
||||
tooling: WorkspaceToolingSettings,
|
||||
discoveredTooling?: DiscoveredToolingState,
|
||||
): WorkspaceToolingSettings {
|
||||
const discoveredMcpServers = listAcceptedDiscoveredMcpServers(discoveredTooling).map((server) =>
|
||||
toResolvedMcpServerDefinition(server, discoveredTooling?.lastScannedAt),
|
||||
);
|
||||
|
||||
if (discoveredMcpServers.length === 0) {
|
||||
return tooling;
|
||||
}
|
||||
|
||||
return {
|
||||
mcpServers: [...tooling.mcpServers, ...discoveredMcpServers],
|
||||
lspProfiles: [...tooling.lspProfiles],
|
||||
};
|
||||
}
|
||||
|
||||
function toResolvedMcpServerDefinition(
|
||||
server: ReturnType<typeof listAcceptedDiscoveredMcpServers>[number],
|
||||
timestamp = nowIso(),
|
||||
): McpServerDefinition {
|
||||
if (server.transport === 'local') {
|
||||
return normalizeMcpServerDefinition({
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
transport: 'local',
|
||||
command: server.command,
|
||||
args: [...server.args],
|
||||
cwd: server.cwd,
|
||||
env: server.env ? { ...server.env } : undefined,
|
||||
tools: [...server.tools],
|
||||
timeoutMs: server.timeoutMs,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
return normalizeMcpServerDefinition({
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
transport: server.transport,
|
||||
url: server.url,
|
||||
headers: server.headers ? { ...server.headers } : undefined,
|
||||
tools: [...server.tools],
|
||||
timeoutMs: server.timeoutMs,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
function requiresTypeScriptLanguageServerStdio(command: string): boolean {
|
||||
const executableName = command
|
||||
.trim()
|
||||
@@ -350,3 +434,20 @@ function normalizeStringArray(values?: ReadonlyArray<string>): string[] {
|
||||
|
||||
return [...new Set(values.map((value) => value.trim()).filter((value) => value.length > 0))];
|
||||
}
|
||||
|
||||
function normalizeStringRecord(record?: Record<string, string>): Record<string, string> | undefined {
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalizedEntries = Object.entries(record)
|
||||
.map(([key, value]) => [key.trim(), value.trim()] as const)
|
||||
.filter(([key, value]) => key.length > 0 && value.length > 0)
|
||||
.sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey));
|
||||
|
||||
if (normalizedEntries.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return Object.fromEntries(normalizedEntries);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user