feat: discover MCP tools via protocol probing for approval pill

When MCP server configs declare wildcard tools (empty tools array),
the Copilot SDK has no API to list individual tool names. Add direct
MCP protocol probing using @modelcontextprotocol/sdk to discover
available tools from each accepted server.

- Add McpToolProber service supporting stdio/SSE/HTTP transports
- Probe accepted MCP servers on project load, acceptance, and rescan
- Store probed tools on DiscoveredMcpServer and McpServerDefinition
- Use probed tools in listApprovalToolDefinitions when declared tools
  are empty, so the approval pill shows individual tool toggles
- Remove unused isMcpServerApprovalKey helper
- Fix effectiveAutoApprovedCount Math.max workaround in ChatPane
- Add comprehensive tests for probed tool behavior
- Update ARCHITECTURE.md tooling integration section

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-28 19:23:57 +01:00
co-authored by Copilot
parent 3937904548
commit 8312a47bf1
10 changed files with 583 additions and 14 deletions
+118 -3
View File
@@ -31,7 +31,9 @@ import {
} from '@shared/domain/pattern';
import {
applyDiscoveredMcpServerStatus,
listAcceptedDiscoveredMcpServers,
normalizeDiscoveredToolingState,
type DiscoveredMcpServer,
type DiscoveredToolingState,
type DiscoveredToolingStatus,
} from '@shared/domain/discoveredTooling';
@@ -126,6 +128,7 @@ import {
} from '@main/sessionToolingConfig';
import { getStoredToken } from '@main/services/mcpTokenStore';
import { performMcpOAuthFlow, requiresOAuth } from '@main/services/mcpOAuthService';
import { probeServers, type McpProbeResult } from '@main/services/mcpToolProber';
const { dialog, shell } = electron;
@@ -226,6 +229,10 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
void this.refreshProjectGitContext().catch((error) => {
console.error('[aryx git]', error);
});
void this.probeAllAcceptedMcpServers(this.workspace).catch((error) => {
console.error('[aryx mcp-probe]', error);
});
}
return this.workspace;
@@ -336,7 +343,15 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
this.pruneUnavailableSessionToolingSelections(workspace);
await this.pruneUnavailableApprovalTools(workspace);
return this.persistAndBroadcast(workspace);
const result = await this.persistAndBroadcast(workspace);
if (resolution === 'accept') {
void this.probeDiscoveredMcpServers(workspace.settings.discoveredUserTooling, serverIds).catch((error) => {
console.error('[aryx mcp-probe]', error);
});
}
return result;
}
async rescanProjectConfigs(projectId: string): Promise<WorkspaceState> {
@@ -345,7 +360,13 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
await this.syncProjectDiscoveredTooling(workspace, project);
this.pruneUnavailableSessionToolingSelections(workspace);
await this.pruneUnavailableApprovalTools(workspace);
return this.persistAndBroadcast(workspace);
const result = await this.persistAndBroadcast(workspace);
void this.probeDiscoveredMcpServersFromState(project.discoveredTooling).catch((error) => {
console.error('[aryx mcp-probe]', error);
});
return result;
}
async rescanProjectCustomization(projectId: string): Promise<WorkspaceState> {
@@ -385,7 +406,15 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
this.pruneUnavailableSessionToolingSelections(workspace);
await this.pruneUnavailableApprovalTools(workspace);
return this.persistAndBroadcast(workspace);
const result = await this.persistAndBroadcast(workspace);
if (resolution === 'accept') {
void this.probeDiscoveredMcpServers(project.discoveredTooling, serverIds).catch((error) => {
console.error('[aryx mcp-probe]', error);
});
}
return result;
}
async savePattern(pattern: PatternDefinition): Promise<WorkspaceState> {
@@ -2100,6 +2129,92 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
return true;
}
private async probeAllAcceptedMcpServers(workspace: WorkspaceState): Promise<void> {
await this.probeDiscoveredMcpServersFromState(workspace.settings.discoveredUserTooling);
for (const project of workspace.projects) {
await this.probeDiscoveredMcpServersFromState(project.discoveredTooling);
}
}
private async probeDiscoveredMcpServersFromState(
state?: DiscoveredToolingState,
): Promise<void> {
const accepted = listAcceptedDiscoveredMcpServers(state);
const serversNeedingProbe = accepted.filter(
(server) => !server.probedTools || server.probedTools.length === 0,
);
if (serversNeedingProbe.length === 0) return;
await this.probeAndApplyResults(state, serversNeedingProbe);
}
private async probeDiscoveredMcpServers(
state: DiscoveredToolingState | undefined,
serverIds: ReadonlyArray<string>,
): Promise<void> {
const accepted = listAcceptedDiscoveredMcpServers(state);
const targets = accepted.filter((server) => serverIds.includes(server.id));
if (targets.length === 0) return;
await this.probeAndApplyResults(state, targets);
}
private async probeAndApplyResults(
state: DiscoveredToolingState | undefined,
targets: ReadonlyArray<DiscoveredMcpServer>,
): Promise<void> {
const tokenLookup = (url: string) => getStoredToken(url)?.accessToken;
const serverDefs = targets.map((server) => this.discoveredServerToDefinition(server));
const results = await probeServers(serverDefs, tokenLookup);
const resultsById = new Map<string, McpProbeResult>(
results.map((r) => [r.serverId, r]),
);
let changed = false;
for (const server of state?.mcpServers ?? []) {
const result = resultsById.get(server.id);
if (result?.status === 'success' && result.tools.length > 0) {
server.probedTools = result.tools;
changed = true;
}
}
if (changed && this.workspace) {
await this.persistAndBroadcast(this.workspace);
}
}
private discoveredServerToDefinition(server: DiscoveredMcpServer): McpServerDefinition {
if (server.transport === 'local') {
return {
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: nowIso(),
updatedAt: nowIso(),
};
}
return {
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: nowIso(),
updatedAt: nowIso(),
};
}
private pruneUnavailableSessionToolingSelections(workspace: WorkspaceState): boolean {
let changed = false;
+171
View File
@@ -0,0 +1,171 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import type { McpServerDefinition } from '@shared/domain/tooling';
export interface McpProbedTool {
name: string;
description?: string;
}
export interface McpProbeResult {
serverId: string;
serverName: string;
tools: McpProbedTool[];
status: 'success' | 'failed';
error?: string;
}
const CLIENT_INFO = { name: 'aryx', version: '1.0.0' };
const DEFAULT_TIMEOUT_MS = 10_000;
const MAX_CONCURRENCY = 5;
export async function probeServers(
servers: ReadonlyArray<McpServerDefinition>,
tokenLookup?: (serverUrl: string) => string | undefined,
): Promise<McpProbeResult[]> {
const pending = [...servers];
const results: McpProbeResult[] = [];
const active: Promise<void>[] = [];
for (const server of pending) {
const task = probeServer(server, tokenLookup).then((result) => {
results.push(result);
});
active.push(task);
if (active.length >= MAX_CONCURRENCY) {
await Promise.race(active);
// Remove settled promises
for (let i = active.length - 1; i >= 0; i--) {
const status = await Promise.race([active[i].then(() => 'done'), Promise.resolve('pending')]);
if (status === 'done') {
active.splice(i, 1);
}
}
}
}
await Promise.allSettled(active);
return results;
}
export async function probeServer(
server: McpServerDefinition,
tokenLookup?: (serverUrl: string) => string | undefined,
): Promise<McpProbeResult> {
const timeoutMs = server.timeoutMs ?? DEFAULT_TIMEOUT_MS;
try {
const tools = await withTimeout(
probeServerCore(server, tokenLookup),
timeoutMs,
`Probe timed out after ${timeoutMs}ms`,
);
return {
serverId: server.id,
serverName: server.name,
tools,
status: 'success',
};
} catch (error) {
return {
serverId: server.id,
serverName: server.name,
tools: [],
status: 'failed',
error: error instanceof Error ? error.message : String(error),
};
}
}
async function probeServerCore(
server: McpServerDefinition,
tokenLookup?: (serverUrl: string) => string | undefined,
): Promise<McpProbedTool[]> {
const transport = createTransport(server, tokenLookup);
const client = new Client(CLIENT_INFO, { capabilities: {} });
try {
await client.connect(transport);
const result = await client.listTools();
return (result.tools ?? [])
.filter((tool) => typeof tool.name === 'string' && tool.name.trim().length > 0)
.map((tool) => ({
name: tool.name.trim(),
description: typeof tool.description === 'string' && tool.description.trim().length > 0
? tool.description.trim()
: undefined,
}));
} finally {
try {
await client.close();
} catch {
// Ignore close errors — connection may already be closed
}
}
}
function createTransport(
server: McpServerDefinition,
tokenLookup?: (serverUrl: string) => string | undefined,
) {
if (server.transport === 'local') {
return new StdioClientTransport({
command: server.command,
args: server.args.length > 0 ? server.args : undefined,
env: server.env
? Object.fromEntries(
Object.entries({ ...process.env, ...server.env })
.filter((entry): entry is [string, string] => entry[1] !== undefined),
)
: undefined,
cwd: server.cwd,
stderr: 'ignore',
});
}
const headers = buildHeaders(server.url, server.headers, tokenLookup);
if (server.transport === 'sse') {
return new SSEClientTransport(
new URL(server.url),
headers ? { requestInit: { headers } } : undefined,
);
}
return new StreamableHTTPClientTransport(
new URL(server.url),
headers ? { requestInit: { headers } } : undefined,
);
}
function buildHeaders(
serverUrl: string,
configHeaders?: Record<string, string>,
tokenLookup?: (serverUrl: string) => string | undefined,
): Record<string, string> | undefined {
const bearerToken = tokenLookup?.(serverUrl);
if (!bearerToken && !configHeaders) {
return undefined;
}
return {
...(configHeaders ?? {}),
...(bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {}),
};
}
function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(message)), ms);
promise.then(
(value) => { clearTimeout(timer); resolve(value); },
(error) => { clearTimeout(timer); reject(error); },
);
});
}
+1 -3
View File
@@ -27,7 +27,6 @@ import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project'
import { resolveSessionToolingSelection, type SessionRecord } from '@shared/domain/session';
import {
groupApprovalToolsByProvider,
isMcpServerApprovalKey,
listApprovalToolDefinitions,
type RuntimeToolDefinition,
type SessionToolingSelection,
@@ -135,8 +134,7 @@ export function ChatPane({
let count = 0;
for (const group of groups) {
if (group.serverApprovalKey && effectiveAutoApproved.has(group.serverApprovalKey)) {
// Server-level approval: count as 1 approved group even with 0 declared tools
count += Math.max(group.tools.length, 1);
count += group.tools.length;
} else {
count += group.tools.filter((t) => effectiveAutoApproved.has(t.id)).length;
}
+1
View File
@@ -7,6 +7,7 @@ export interface BaseDiscoveredMcpServer {
name: string;
transport: DiscoveredMcpServerTransport;
tools: string[];
probedTools?: { name: string; description?: string }[];
timeoutMs?: number;
scope: DiscoveredToolingScope;
scannerId: string;
+21 -7
View File
@@ -9,11 +9,17 @@ import { nowIso } from '@shared/utils/ids';
export type McpServerTransport = 'local' | 'http' | 'sse';
export interface McpProbedTool {
name: string;
description?: string;
}
export interface BaseMcpServerDefinition {
id: string;
name: string;
transport: McpServerTransport;
tools: string[];
probedTools?: McpProbedTool[];
timeoutMs?: number;
createdAt: string;
updatedAt: string;
@@ -155,10 +161,6 @@ export function buildMcpServerApprovalKey(serverName: string): string {
return `${MCP_SERVER_APPROVAL_PREFIX}${serverName}`;
}
export function isMcpServerApprovalKey(key: string): boolean {
return key.startsWith(MCP_SERVER_APPROVAL_PREFIX);
}
export function createWorkspaceSettings(): WorkspaceSettings {
return {
theme: 'dark',
@@ -240,10 +242,16 @@ export function listApprovalToolDefinitions(
}
for (const server of tooling.mcpServers) {
for (const toolName of normalizeStringArray(server.tools)) {
const declaredTools = normalizeStringArray(server.tools);
const toolEntries = declaredTools.length > 0
? declaredTools.map((name) => ({ name, description: undefined }))
: (server.probedTools ?? []).filter((t) => t.name.trim().length > 0);
for (const tool of toolEntries) {
registerApprovalTool(toolsById, {
id: toolName,
label: toolName,
id: tool.name,
label: tool.name,
description: tool.description,
kind: 'mcp',
providerId: server.id,
providerName: server.name,
@@ -469,6 +477,10 @@ function toResolvedMcpServerDefinition(
server: ReturnType<typeof listAcceptedDiscoveredMcpServers>[number],
timestamp = nowIso(),
): McpServerDefinition {
const probedTools = server.probedTools && server.probedTools.length > 0
? [...server.probedTools]
: undefined;
if (server.transport === 'local') {
return normalizeMcpServerDefinition({
id: server.id,
@@ -479,6 +491,7 @@ function toResolvedMcpServerDefinition(
cwd: server.cwd,
env: server.env ? { ...server.env } : undefined,
tools: [...server.tools],
probedTools,
timeoutMs: server.timeoutMs,
createdAt: timestamp,
updatedAt: timestamp,
@@ -492,6 +505,7 @@ function toResolvedMcpServerDefinition(
url: server.url,
headers: server.headers ? { ...server.headers } : undefined,
tools: [...server.tools],
probedTools,
timeoutMs: server.timeoutMs,
createdAt: timestamp,
updatedAt: timestamp,