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
@@ -0,0 +1,267 @@
import { useCallback, useEffect, useMemo } from 'react';
import { Check, FileSearch, Server, X, XCircle } from 'lucide-react';
import type { DiscoveredMcpServer, DiscoveredToolingState } from '@shared/domain/discoveredTooling';
import { listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling';
import type { ProjectDiscoveredTooling } from '@shared/domain/discoveredTooling';
/* ── Props ─────────────────────────────────────────────────── */
interface DiscoveredToolingModalProps {
userDiscoveredTooling: DiscoveredToolingState;
projectDiscoveredTooling?: ProjectDiscoveredTooling;
projectName?: string;
onResolveUserServers: (serverIds: string[], resolution: 'accept' | 'dismiss') => void;
onResolveProjectServers: (serverIds: string[], resolution: 'accept' | 'dismiss') => void;
onClose: () => void;
}
/* ── Modal ─────────────────────────────────────────────────── */
export function DiscoveredToolingModal({
userDiscoveredTooling,
projectDiscoveredTooling,
projectName,
onResolveUserServers,
onResolveProjectServers,
onClose,
}: DiscoveredToolingModalProps) {
const pendingUserServers = useMemo(
() => listPendingDiscoveredMcpServers(userDiscoveredTooling),
[userDiscoveredTooling],
);
const pendingProjectServers = useMemo(
() => listPendingDiscoveredMcpServers(projectDiscoveredTooling),
[projectDiscoveredTooling],
);
const totalPending = pendingUserServers.length + pendingProjectServers.length;
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
},
[onClose],
);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
// Auto-close when nothing left to review
useEffect(() => {
if (totalPending === 0) onClose();
}, [totalPending, onClose]);
if (totalPending === 0) return null;
const userGroups = groupBySource(pendingUserServers);
const projectGroups = groupBySource(pendingProjectServers);
function handleAcceptAll() {
if (pendingUserServers.length > 0) {
onResolveUserServers(pendingUserServers.map((s) => s.id), 'accept');
}
if (pendingProjectServers.length > 0) {
onResolveProjectServers(pendingProjectServers.map((s) => s.id), 'accept');
}
}
function handleDismissAll() {
if (pendingUserServers.length > 0) {
onResolveUserServers(pendingUserServers.map((s) => s.id), 'dismiss');
}
if (pendingProjectServers.length > 0) {
onResolveProjectServers(pendingProjectServers.map((s) => s.id), 'dismiss');
}
}
return (
<div
aria-labelledby="discovered-tooling-title"
aria-modal="true"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
role="dialog"
>
<div className="flex max-h-[80vh] w-full max-w-lg flex-col rounded-xl border border-zinc-800 bg-zinc-900 shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between border-b border-zinc-800 px-5 py-4">
<div className="flex items-center gap-2.5">
<FileSearch className="size-4 text-indigo-400" />
<h2 id="discovered-tooling-title" className="text-[13px] font-semibold text-zinc-100">
MCP servers found in config files
</h2>
</div>
<button
className="flex size-7 items-center justify-center rounded-lg text-zinc-500 transition hover:bg-zinc-800 hover:text-zinc-300"
onClick={onClose}
type="button"
>
<X className="size-4" />
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-5 py-4">
<p className="mb-4 text-[12px] leading-relaxed text-zinc-500">
The following MCP servers were found in your config files. Accept the ones you want to
use, or dismiss those you don&apos;t need. Accepted servers become available for session tooling.
</p>
{pendingUserServers.length > 0 && (
<DiscoveredGroup
groups={userGroups}
onResolve={onResolveUserServers}
scopeLabel="User-level"
/>
)}
{pendingProjectServers.length > 0 && (
<DiscoveredGroup
groups={projectGroups}
onResolve={onResolveProjectServers}
scopeLabel={projectName ? `Project: ${projectName}` : 'Project-level'}
/>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between border-t border-zinc-800 px-5 py-3">
<span className="text-[12px] text-zinc-600">
{totalPending} server{totalPending === 1 ? '' : 's'} pending review
</span>
<div className="flex items-center gap-2">
<button
className="rounded-lg px-3 py-1.5 text-[13px] text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-200"
onClick={handleDismissAll}
type="button"
>
Dismiss All
</button>
<button
className="rounded-lg bg-indigo-600 px-3 py-1.5 text-[13px] font-medium text-white transition hover:bg-indigo-500"
onClick={handleAcceptAll}
type="button"
>
Accept All
</button>
</div>
</div>
</div>
</div>
);
}
/* ── Discovered group (by scope) ──────────────────────────── */
function DiscoveredGroup({
scopeLabel,
groups,
onResolve,
}: {
scopeLabel: string;
groups: SourceGroup[];
onResolve: (serverIds: string[], resolution: 'accept' | 'dismiss') => void;
}) {
return (
<div className="mb-4">
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-zinc-600">
{scopeLabel}
</div>
{groups.map((group) => (
<div className="mb-3" key={group.sourceLabel}>
<div className="mb-1.5 flex items-center gap-1.5 text-[11px] text-zinc-500">
<span className="truncate font-medium">{group.sourceLabel}</span>
<span className="text-zinc-700">·</span>
<span className="text-zinc-600">
{group.servers.length} server{group.servers.length === 1 ? '' : 's'}
</span>
</div>
<div className="space-y-1">
{group.servers.map((server) => (
<ServerRow
key={server.id}
onAccept={() => onResolve([server.id], 'accept')}
onDismiss={() => onResolve([server.id], 'dismiss')}
server={server}
/>
))}
</div>
</div>
))}
</div>
);
}
/* ── Server row ────────────────────────────────────────────── */
function ServerRow({
server,
onAccept,
onDismiss,
}: {
server: DiscoveredMcpServer;
onAccept: () => void;
onDismiss: () => void;
}) {
const detail =
server.transport === 'local'
? server.command || 'No command'
: server.url || 'No URL';
return (
<div className="group flex items-center gap-3 rounded-lg border border-zinc-800/60 bg-zinc-800/20 px-3 py-2.5">
<Server className="size-3.5 shrink-0 text-zinc-600" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-[13px] font-medium text-zinc-200">
{server.name}
</span>
<span className="rounded-full bg-zinc-800 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-zinc-500">
{server.transport}
</span>
</div>
<p className="mt-0.5 truncate text-[12px] text-zinc-500">{detail}</p>
</div>
<div className="flex items-center gap-1">
<button
className="flex size-7 items-center justify-center rounded-md text-zinc-600 transition hover:bg-red-500/10 hover:text-red-400"
onClick={onDismiss}
title="Dismiss"
type="button"
>
<XCircle className="size-3.5" />
</button>
<button
className="flex size-7 items-center justify-center rounded-md text-zinc-600 transition hover:bg-emerald-500/10 hover:text-emerald-400"
onClick={onAccept}
title="Accept"
type="button"
>
<Check className="size-3.5" />
</button>
</div>
</div>
);
}
/* ── Helpers ────────────────────────────────────────────────── */
interface SourceGroup {
sourceLabel: string;
servers: DiscoveredMcpServer[];
}
function groupBySource(servers: DiscoveredMcpServer[]): SourceGroup[] {
const map = new Map<string, DiscoveredMcpServer[]>();
for (const server of servers) {
const group = map.get(server.sourceLabel) ?? [];
group.push(server);
map.set(server.sourceLabel, group);
}
return [...map.entries()]
.map(([sourceLabel, groupServers]) => ({ sourceLabel, servers: groupServers }))
.sort((a, b) => a.sourceLabel.localeCompare(b.sourceLabel));
}
+200 -1
View File
@@ -1,11 +1,13 @@
import { useState, type ReactNode } from 'react';
import { ChevronLeft, ChevronRight, Code, Cpu, FolderOpen, Palette, Plus, Server, TriangleAlert, Workflow, Wrench } from 'lucide-react';
import { ChevronLeft, ChevronRight, Code, Cpu, FolderOpen, Palette, Plus, RefreshCw, Server, TriangleAlert, Workflow, Wrench } from 'lucide-react';
import { CopilotStatusCard } from '@renderer/components/CopilotStatusCard';
import { PatternEditor } from '@renderer/components/PatternEditor';
import { LspProfileEditor } from '@renderer/components/settings/LspProfileEditor';
import { McpServerEditor } from '@renderer/components/settings/McpServerEditor';
import type { SidecarCapabilities } from '@shared/contracts/sidecar';
import type { DiscoveredMcpServer, DiscoveredToolingState, ProjectDiscoveredTooling } from '@shared/domain/discoveredTooling';
import { listAcceptedDiscoveredMcpServers, listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling';
import type { ModelDefinition } from '@shared/domain/models';
import type { PatternDefinition } from '@shared/domain/pattern';
import {
@@ -23,6 +25,9 @@ interface SettingsPanelProps {
sidecarCapabilities?: SidecarCapabilities;
theme: AppearanceTheme;
toolingSettings: WorkspaceToolingSettings;
discoveredUserTooling: DiscoveredToolingState;
discoveredProjectTooling?: ProjectDiscoveredTooling;
selectedProjectName?: string;
isRefreshingCapabilities: boolean;
onRefreshCapabilities: () => void;
onClose: () => void;
@@ -38,6 +43,9 @@ interface SettingsPanelProps {
onSetTheme: (theme: AppearanceTheme) => void;
onOpenAppDataFolder: () => void;
onResetLocalWorkspace: () => Promise<void>;
onRescanProjectConfigs?: () => void;
onResolveUserDiscoveredTooling?: (serverIds: string[], resolution: 'accept' | 'dismiss') => void;
onResolveProjectDiscoveredTooling?: (serverIds: string[], resolution: 'accept' | 'dismiss') => void;
}
type SettingsSection = 'appearance' | 'connection' | 'patterns' | 'mcp-servers' | 'lsp-profiles' | 'troubleshooting';
@@ -98,6 +106,9 @@ export function SettingsPanel({
sidecarCapabilities,
theme,
toolingSettings,
discoveredUserTooling,
discoveredProjectTooling,
selectedProjectName,
isRefreshingCapabilities,
onRefreshCapabilities,
onClose,
@@ -113,6 +124,9 @@ export function SettingsPanel({
onSetTheme,
onOpenAppDataFolder,
onResetLocalWorkspace,
onRescanProjectConfigs,
onResolveUserDiscoveredTooling,
onResolveProjectDiscoveredTooling,
}: SettingsPanelProps) {
const [activeSection, setActiveSection] = useState<SettingsSection>('appearance');
const [editingPattern, setEditingPattern] = useState<PatternDefinition | null>(null);
@@ -271,6 +285,16 @@ export function SettingsPanel({
servers={toolingSettings.mcpServers}
/>
)}
{activeSection === 'mcp-servers' && (
<DiscoveredMcpSection
discoveredProjectTooling={discoveredProjectTooling}
discoveredUserTooling={discoveredUserTooling}
onRescanProjectConfigs={onRescanProjectConfigs}
onResolveProjectDiscoveredTooling={onResolveProjectDiscoveredTooling}
onResolveUserDiscoveredTooling={onResolveUserDiscoveredTooling}
selectedProjectName={selectedProjectName}
/>
)}
{activeSection === 'lsp-profiles' && (
<LspProfilesSection
onEditProfile={(profile) => setEditingLspProfile(structuredClone(profile))}
@@ -579,6 +603,181 @@ function EmptyState({ children }: { children: ReactNode }) {
);
}
/* ── Discovered MCP section ────────────────────────────────── */
function DiscoveredMcpSection({
discoveredUserTooling,
discoveredProjectTooling,
selectedProjectName,
onRescanProjectConfigs,
onResolveUserDiscoveredTooling,
onResolveProjectDiscoveredTooling,
}: {
discoveredUserTooling: DiscoveredToolingState;
discoveredProjectTooling?: ProjectDiscoveredTooling;
selectedProjectName?: string;
onRescanProjectConfigs?: () => void;
onResolveUserDiscoveredTooling?: (serverIds: string[], resolution: 'accept' | 'dismiss') => void;
onResolveProjectDiscoveredTooling?: (serverIds: string[], resolution: 'accept' | 'dismiss') => void;
}) {
const acceptedUser = listAcceptedDiscoveredMcpServers(discoveredUserTooling);
const pendingUser = listPendingDiscoveredMcpServers(discoveredUserTooling);
const acceptedProject = listAcceptedDiscoveredMcpServers(discoveredProjectTooling);
const pendingProject = listPendingDiscoveredMcpServers(discoveredProjectTooling);
const hasAny = acceptedUser.length + pendingUser.length + acceptedProject.length + pendingProject.length > 0;
if (!hasAny) return null;
return (
<div className="mt-8">
<SectionHeader
description="MCP servers discovered from project and user config files. Accepted servers are available for session tooling."
title="Discovered MCP Servers"
>
{onRescanProjectConfigs && (
<button
className="flex items-center gap-1.5 rounded-lg bg-zinc-800 px-3 py-1.5 text-[13px] font-medium text-zinc-200 transition hover:bg-zinc-700"
onClick={onRescanProjectConfigs}
title="Re-scan project config files"
type="button"
>
<RefreshCw className="size-3.5" />
Re-scan
</button>
)}
</SectionHeader>
{/* User-level discovered */}
{(acceptedUser.length > 0 || pendingUser.length > 0) && (
<DiscoveredSubSection
label="User-level"
description="From ~/.copilot/mcp.json"
accepted={acceptedUser}
pending={pendingUser}
onResolve={onResolveUserDiscoveredTooling}
/>
)}
{/* Project-level discovered */}
{(acceptedProject.length > 0 || pendingProject.length > 0) && (
<DiscoveredSubSection
label={selectedProjectName ? `Project: ${selectedProjectName}` : 'Project-level'}
description="From .vscode/mcp.json, .mcp.json, or .copilot/mcp.json"
accepted={acceptedProject}
pending={pendingProject}
onResolve={onResolveProjectDiscoveredTooling}
/>
)}
</div>
);
}
function DiscoveredSubSection({
label,
description,
accepted,
pending,
onResolve,
}: {
label: string;
description: string;
accepted: DiscoveredMcpServer[];
pending: DiscoveredMcpServer[];
onResolve?: (serverIds: string[], resolution: 'accept' | 'dismiss') => void;
}) {
return (
<div className="mb-4">
<div className="mb-2 flex items-center justify-between">
<div>
<span className="text-[12px] font-medium text-zinc-300">{label}</span>
<p className="text-[11px] text-zinc-600">{description}</p>
</div>
</div>
<div className="space-y-1">
{accepted.map((server) => (
<DiscoveredServerRow
key={server.id}
onDismiss={onResolve ? () => onResolve([server.id], 'dismiss') : undefined}
server={server}
status="accepted"
/>
))}
{pending.map((server) => (
<DiscoveredServerRow
key={server.id}
onAccept={onResolve ? () => onResolve([server.id], 'accept') : undefined}
onDismiss={onResolve ? () => onResolve([server.id], 'dismiss') : undefined}
server={server}
status="pending"
/>
))}
</div>
</div>
);
}
function DiscoveredServerRow({
server,
status,
onAccept,
onDismiss,
}: {
server: DiscoveredMcpServer;
status: 'accepted' | 'pending';
onAccept?: () => void;
onDismiss?: () => void;
}) {
const detail =
server.transport === 'local'
? server.command || 'No command'
: server.url || 'No URL';
const statusBadge = status === 'accepted'
? 'bg-emerald-500/10 text-emerald-400'
: 'bg-amber-500/10 text-amber-400';
return (
<div className="flex items-center gap-3 rounded-xl border border-transparent px-4 py-3 hover:border-zinc-800 hover:bg-zinc-900">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-[13px] font-medium text-zinc-200">{server.name}</span>
<span className="rounded-full bg-zinc-800 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-zinc-400">
{server.transport}
</span>
<span className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${statusBadge}`}>
{status}
</span>
</div>
<p className="mt-0.5 truncate text-[12px] text-zinc-500">
{detail}
<span className="ml-2 text-zinc-700">· {server.sourceLabel}</span>
</p>
</div>
<div className="flex items-center gap-1">
{onAccept && (
<button
className="rounded-lg px-2.5 py-1 text-[12px] font-medium text-emerald-400 transition hover:bg-emerald-500/10"
onClick={onAccept}
type="button"
>
Accept
</button>
)}
{onDismiss && (
<button
className="rounded-lg px-2.5 py-1 text-[12px] font-medium text-zinc-500 transition hover:bg-zinc-800 hover:text-zinc-300"
onClick={onDismiss}
type="button"
>
{status === 'accepted' ? 'Remove' : 'Dismiss'}
</button>
)}
</div>
</div>
);
}
function TroubleshootingSection({
onOpenAppDataFolder,
onResetLocalWorkspace,
+13
View File
@@ -28,6 +28,7 @@ import {
import type { OrchestrationMode, PatternDefinition } from '@shared/domain/pattern';
import { isScratchpadProject, type ProjectRecord, type ProjectGitContext } from '@shared/domain/project';
import { listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling';
import type { SessionRecord } from '@shared/domain/session';
import { querySessions } from '@shared/domain/sessionLibrary';
import type { WorkspaceState } from '@shared/domain/workspace';
@@ -360,6 +361,10 @@ function ProjectGroup({
);
const runningCount = visibleSessions.filter((s) => s.status === 'running').length;
const pendingDiscoveryCount = useMemo(
() => isScratchpad ? 0 : listPendingDiscoveredMcpServers(project.discoveredTooling).length,
[isScratchpad, project.discoveredTooling],
);
return (
<div>
@@ -404,6 +409,14 @@ function ProjectGroup({
{runningCount}
</span>
)}
{pendingDiscoveryCount > 0 && (
<span
className="flex items-center gap-1 rounded-full bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium text-amber-400"
title={`${pendingDiscoveryCount} MCP server${pendingDiscoveryCount === 1 ? '' : 's'} discovered`}
>
{pendingDiscoveryCount} new
</span>
)}
<span className="rounded-full bg-zinc-800 px-1.5 py-0.5 text-[10px] font-medium text-zinc-500">
{visibleSessions.length}
</span>
+61 -20
View File
@@ -225,6 +225,10 @@ export function InlineToolsPill({
const enabledCount = selection.enabledMcpServerIds.length + selection.enabledLspProfileIds.length;
const totalCount = mcpServers.length + lspProfiles.length;
const workspaceMcpServers = mcpServers.filter((s) => !s.id.startsWith('discovered_'));
const discoveredUserMcpServers = mcpServers.filter((s) => s.id.startsWith('discovered_user_'));
const discoveredProjectMcpServers = mcpServers.filter((s) => s.id.startsWith('discovered_project_'));
return (
<div className="relative" ref={ref}>
<button
@@ -246,26 +250,29 @@ export function InlineToolsPill({
{open && !disabled && (
<div className="absolute bottom-full left-0 z-40 mb-1.5 w-64 overflow-y-auto rounded-lg border border-zinc-700 bg-zinc-900 py-1 shadow-2xl">
{mcpServers.length > 0 && (
<div>
<div className="px-3 pb-1 pt-2 text-[9px] font-semibold uppercase tracking-wider text-zinc-600">
MCP Servers
</div>
{mcpServers.map((server) => (
<PopoverToggleRow
detail={server.transport === 'local' ? server.command : server.url}
enabled={selection.enabledMcpServerIds.includes(server.id)}
key={server.id}
label={server.name}
onToggle={() =>
onToggle({
...selection,
enabledMcpServerIds: toggleInArray(selection.enabledMcpServerIds, server.id),
})
}
/>
))}
</div>
{workspaceMcpServers.length > 0 && (
<McpServerGroup
label="Workspace MCP"
onToggle={onToggle}
selection={selection}
servers={workspaceMcpServers}
/>
)}
{discoveredUserMcpServers.length > 0 && (
<McpServerGroup
label="User MCP"
onToggle={onToggle}
selection={selection}
servers={discoveredUserMcpServers}
/>
)}
{discoveredProjectMcpServers.length > 0 && (
<McpServerGroup
label="Project MCP"
onToggle={onToggle}
selection={selection}
servers={discoveredProjectMcpServers}
/>
)}
{lspProfiles.length > 0 && (
<div>
@@ -294,6 +301,40 @@ export function InlineToolsPill({
);
}
function McpServerGroup({
label,
servers,
selection,
onToggle,
}: {
label: string;
servers: ReadonlyArray<McpServerDefinition>;
selection: SessionToolingSelection;
onToggle: (selection: SessionToolingSelection) => void;
}) {
return (
<div>
<div className="px-3 pb-1 pt-2 text-[9px] font-semibold uppercase tracking-wider text-zinc-600">
{label}
</div>
{servers.map((server) => (
<PopoverToggleRow
detail={server.transport === 'local' ? server.command : server.url}
enabled={selection.enabledMcpServerIds.includes(server.id)}
key={server.id}
label={server.name}
onToggle={() =>
onToggle({
...selection,
enabledMcpServerIds: toggleInArray(selection.enabledMcpServerIds, server.id),
})
}
/>
))}
</div>
);
}
/* ── InlineApprovalPill ────────────────────────────────────── */
const approvalKindOrder: ApprovalToolKind[] = ['builtin', 'mcp', 'lsp', 'mixed'];