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
+288
View File
@@ -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')}`;
}
+2
View File
@@ -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';
+101
View File
@@ -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);
}