fullcourtdefense-cli 1.1.17 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -53,8 +53,41 @@ fullcourtdefense init
53
53
  - `fullcourtdefense install-claude-code-mcp-gateway ...` — registers the protected MCP gateway in Claude Code.
54
54
  - `fullcourtdefense install-claude-desktop-mcp-gateway ...` — registers the protected MCP gateway in Claude Desktop.
55
55
  - `fullcourtdefense credits` — checks hosted scan credits for CI/CD API-key scans.
56
+ - `fullcourtdefense discover` — scans local MCP client configs and reports risk; `--upload` sends desktop inventory to AI Inventory.
56
57
  - `fullcourtdefense init` — creates a starter config file.
57
58
 
59
+ ## Shadow AI / Desktop Discovery
60
+
61
+ Find MCP servers configured on developer machines without reading chat history or spawning stdio servers by default:
62
+
63
+ ```bash
64
+ # Scan this machine and print results
65
+ fullcourtdefense discover
66
+
67
+ # JSON output (includes host fingerprint + servers)
68
+ fullcourtdefense discover --json
69
+
70
+ # Upload to AI Inventory (requires CI/CD API key with audit:write)
71
+ fullcourtdefense discover --upload --api-key YOUR_KEY
72
+
73
+ # Live-probe HTTP/SSE MCP servers for tools/list (stdio stays config-only)
74
+ fullcourtdefense discover --deep
75
+
76
+ # Fleet / MDM script — silent upload with user attribution
77
+ fullcourtdefense discover --upload --silent --user-email you@company.com --api-key YOUR_KEY
78
+
79
+ # Daily automatic scan on this laptop (uses API key from env or ~/.fullcourtdefense.yml)
80
+ fullcourtdefense discover --schedule daily --user-email you@company.com
81
+
82
+ # Remove daily schedule
83
+ fullcourtdefense discover --unschedule true
84
+
85
+ # Scan an extra config path
86
+ fullcourtdefense discover --path /path/to/mcp.json
87
+ ```
88
+
89
+ Each upload includes host metadata (`machineId`, `hostname`, `platform`, `user`) so the same MCP server on different laptops appears as separate desktop inventory rows. The CLI is also available as `fcd` or `botguard` after global install.
90
+
58
91
  ## Config File
59
92
 
60
93
  Create a `.fullcourtdefense.yml` to avoid passing flags every time:
@@ -7,5 +7,27 @@ export interface DiscoverArgs {
7
7
  upload?: string;
8
8
  connectorName?: string;
9
9
  extraPath?: string;
10
+ deep?: string;
11
+ silent?: string;
12
+ userEmail?: string;
13
+ schedule?: string;
14
+ unschedule?: string;
15
+ scheduleHour?: string;
16
+ }
17
+ export interface DesktopDiscoveryHost {
18
+ machineId: string;
19
+ hostname: string;
20
+ platform: string;
21
+ user: string;
22
+ scannedAt: string;
23
+ probeMode: 'config' | 'deep';
10
24
  }
11
25
  export declare function discoverCommand(args: DiscoverArgs, config: BotGuardConfig): Promise<void>;
26
+ /** Upload-only discover — used after install-mcp-gateway --upload. */
27
+ export declare function runDiscoverUpload(args: {
28
+ apiKey?: string;
29
+ apiUrl?: string;
30
+ userEmail?: string;
31
+ silent?: boolean;
32
+ connectorName?: string;
33
+ }, config: BotGuardConfig): Promise<void>;
@@ -34,56 +34,35 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.discoverCommand = discoverCommand;
37
+ exports.runDiscoverUpload = runDiscoverUpload;
38
+ const crypto = __importStar(require("crypto"));
37
39
  const fs = __importStar(require("fs"));
38
40
  const os = __importStar(require("os"));
39
- const path = __importStar(require("path"));
41
+ const discoverProxy_1 = require("./discoverProxy");
42
+ const discoverSchedule_1 = require("./discoverSchedule");
43
+ const discoverPaths_1 = require("./discoverPaths");
44
+ const knownMcpServers_1 = require("./knownMcpServers");
40
45
  const DEFAULT_API_URL = 'https://api.fullcourtdefense.ai';
41
46
  const SECRET_VALUE = /sk-[A-Za-z0-9_-]{12,}|ghp_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{20,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{20,}/;
42
47
  const SECRET_KEY = /key|token|secret|password|credential|api[_-]?key/i;
43
- /** Where MCP client configs live, by client + OS. */
48
+ function machineFingerprint() {
49
+ const seed = [os.hostname(), os.userInfo().username, os.platform(), os.arch()].join('|');
50
+ return crypto.createHash('sha256').update(seed).digest('hex').slice(0, 16);
51
+ }
52
+ function buildHostMetadata(userEmail, probeMode = 'config') {
53
+ const info = os.userInfo();
54
+ return {
55
+ machineId: machineFingerprint(),
56
+ hostname: os.hostname(),
57
+ platform: os.platform(),
58
+ user: userEmail || info.username,
59
+ scannedAt: new Date().toISOString(),
60
+ probeMode,
61
+ };
62
+ }
63
+ /** Where MCP client configs live — see discoverPaths.ts for the canonical list. */
44
64
  function candidateConfigPaths(cwd, extra) {
45
- const home = os.homedir();
46
- const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
47
- const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
48
- const xdg = process.env.XDG_CONFIG_HOME || path.join(home, '.config');
49
- const list = [
50
- // Cursor
51
- { path: path.join(home, '.cursor', 'mcp.json'), source: 'Cursor (global)' },
52
- { path: path.join(cwd, '.cursor', 'mcp.json'), source: 'Cursor (project)' },
53
- // Claude Desktop
54
- { path: path.join(appData, 'Claude', 'claude_desktop_config.json'), source: 'Claude Desktop (win)' },
55
- { path: path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'), source: 'Claude Desktop (mac)' },
56
- { path: path.join(xdg, 'Claude', 'claude_desktop_config.json'), source: 'Claude Desktop (linux)' },
57
- // VS Code (project + user settings)
58
- { path: path.join(cwd, '.vscode', 'mcp.json'), source: 'VS Code (project)' },
59
- { path: path.join(appData, 'Code', 'User', 'settings.json'), source: 'VS Code (user, win)' },
60
- { path: path.join(home, 'Library', 'Application Support', 'Code', 'User', 'settings.json'), source: 'VS Code (user, mac)' },
61
- { path: path.join(xdg, 'Code', 'User', 'settings.json'), source: 'VS Code (user, linux)' },
62
- // Windsurf
63
- { path: path.join(home, '.codeium', 'windsurf', 'mcp_config.json'), source: 'Windsurf' },
64
- // Generic / repo-root
65
- { path: path.join(cwd, '.mcp.json'), source: 'Repo (.mcp.json)' },
66
- { path: path.join(cwd, 'mcp.json'), source: 'Repo (mcp.json)' },
67
- ];
68
- if (process.platform === 'win32') {
69
- const packagesDir = path.join(localAppData, 'Packages');
70
- try {
71
- for (const packageName of fs.readdirSync(packagesDir)) {
72
- if (!/^Claude_/i.test(packageName))
73
- continue;
74
- list.push({
75
- path: path.join(packagesDir, packageName, 'LocalCache', 'Roaming', 'Claude', 'claude_desktop_config.json'),
76
- source: 'Claude Desktop (win MSIX)',
77
- });
78
- }
79
- }
80
- catch {
81
- // Missing Packages directory is normal on non-Store installs.
82
- }
83
- }
84
- if (extra)
85
- list.push({ path: path.resolve(extra), source: 'Custom path' });
86
- return list;
65
+ return (0, discoverPaths_1.discoverScanTargets)(cwd, extra);
87
66
  }
88
67
  /** MCP servers can be declared under several keys across clients. */
89
68
  function extractServerMap(parsed) {
@@ -137,7 +116,7 @@ function parseConfigFile(filePath, source) {
137
116
  parsed = JSON.parse(raw);
138
117
  }
139
118
  catch {
140
- return []; // settings.json with comments/JSONC — best-effort skip
119
+ return [];
141
120
  }
142
121
  const servers = extractServerMap(parsed);
143
122
  const out = [];
@@ -152,7 +131,15 @@ function parseConfigFile(filePath, source) {
152
131
  ? (/\/sse(\b|$)/.test(url) ? 'sse' : 'http')
153
132
  : command ? 'stdio' : 'unknown';
154
133
  const tools = Array.isArray(cfg.tools)
155
- ? cfg.tools.map((t) => (typeof t === 'string' ? t : t?.name)).filter(Boolean).map(String)
134
+ ? cfg.tools
135
+ .map((t) => {
136
+ if (typeof t === 'string')
137
+ return { name: t };
138
+ if (t && typeof t.name === 'string')
139
+ return { name: t.name, description: typeof t.description === 'string' ? t.description : undefined };
140
+ return null;
141
+ })
142
+ .filter(Boolean)
156
143
  : [];
157
144
  const { level, tags } = scoreRisk({ serverName: name, command, args, url });
158
145
  const warnings = [];
@@ -178,10 +165,97 @@ function parseConfigFile(filePath, source) {
178
165
  riskLevel: level,
179
166
  riskTags: tags,
180
167
  warnings,
168
+ probeMode: 'config',
169
+ proxyStatus: 'unknown',
181
170
  });
182
171
  }
183
172
  return out;
184
173
  }
174
+ async function mcpJsonRpc(endpoint, method, params, timeoutMs, sessionId) {
175
+ const headers = {
176
+ 'Content-Type': 'application/json',
177
+ Accept: 'application/json, text/event-stream',
178
+ };
179
+ if (sessionId)
180
+ headers['Mcp-Session-Id'] = sessionId;
181
+ const controller = new AbortController();
182
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
183
+ try {
184
+ const resp = await fetch(endpoint, {
185
+ method: 'POST',
186
+ headers,
187
+ body: JSON.stringify({ jsonrpc: '2.0', id: Date.now(), method, params }),
188
+ signal: controller.signal,
189
+ });
190
+ const nextSession = resp.headers.get('mcp-session-id') || sessionId;
191
+ const ct = resp.headers.get('content-type') || '';
192
+ if (ct.includes('text/event-stream')) {
193
+ const text = await resp.text();
194
+ const dataLine = text.split('\n').find(line => line.startsWith('data: '));
195
+ return { json: dataLine ? JSON.parse(dataLine.slice(6)) : null, sessionId: nextSession || undefined };
196
+ }
197
+ return { json: await resp.json(), sessionId: nextSession || undefined };
198
+ }
199
+ finally {
200
+ clearTimeout(timer);
201
+ }
202
+ }
203
+ /** Best-effort live tools/list for HTTP/SSE MCP servers (stdio skipped — would spawn processes). */
204
+ async function probeHttpMcpTools(url, timeoutMs = 8000) {
205
+ const normalized = url.replace(/\/$/, '');
206
+ const candidates = [...new Set([
207
+ normalized,
208
+ normalized.replace(/\/sse(\?.*)?$/i, ''),
209
+ `${normalized}/mcp`,
210
+ normalized.replace(/\/sse(\?.*)?$/i, '/mcp'),
211
+ ])];
212
+ for (const endpoint of candidates) {
213
+ try {
214
+ const init = await mcpJsonRpc(endpoint, 'initialize', {
215
+ protocolVersion: '2024-11-05',
216
+ capabilities: {},
217
+ clientInfo: { name: 'fullcourtdefense-discover', version: '1.0.0' },
218
+ }, timeoutMs);
219
+ const sid = init.sessionId;
220
+ if (init.json?.error)
221
+ continue;
222
+ await mcpJsonRpc(endpoint, 'notifications/initialized', {}, timeoutMs, sid);
223
+ const listed = await mcpJsonRpc(endpoint, 'tools/list', {}, timeoutMs, sid);
224
+ const tools = listed.json?.result?.tools;
225
+ if (Array.isArray(tools) && tools.length > 0) {
226
+ return tools
227
+ .map((t) => (t && typeof t.name === 'string' ? { name: t.name, description: typeof t.description === 'string' ? t.description : undefined } : null))
228
+ .filter(Boolean);
229
+ }
230
+ }
231
+ catch {
232
+ // try next endpoint variant
233
+ }
234
+ }
235
+ return [];
236
+ }
237
+ async function enrichWithDeepProbe(servers, silent) {
238
+ for (const server of servers) {
239
+ if (server.transport !== 'http' && server.transport !== 'sse') {
240
+ if (!silent && server.transport === 'stdio') {
241
+ server.warnings.push('Deep probe skipped for stdio server (config-only inventory).');
242
+ }
243
+ continue;
244
+ }
245
+ if (!server.url)
246
+ continue;
247
+ const liveTools = await probeHttpMcpTools(server.url);
248
+ if (liveTools.length === 0) {
249
+ server.warnings.push('Deep probe could not list tools (server may require auth or be offline).');
250
+ continue;
251
+ }
252
+ const byName = new Map(server.tools.map(t => [t.name, t]));
253
+ for (const tool of liveTools)
254
+ byName.set(tool.name, tool);
255
+ server.tools = [...byName.values()];
256
+ server.probeMode = 'deep';
257
+ }
258
+ }
185
259
  /** Merge servers found in multiple files; keep richest record, union sources. */
186
260
  function dedupe(servers) {
187
261
  const byName = new Map();
@@ -195,8 +269,13 @@ function dedupe(servers) {
195
269
  existing.source += `, ${s.source}`;
196
270
  existing.command = existing.command || s.command;
197
271
  existing.url = existing.url || s.url;
198
- existing.tools = Array.from(new Set([...existing.tools, ...s.tools]));
272
+ const toolMap = new Map(existing.tools.map(t => [t.name, t]));
273
+ for (const t of s.tools)
274
+ toolMap.set(t.name, t);
275
+ existing.tools = [...toolMap.values()];
199
276
  existing.warnings = Array.from(new Set([...existing.warnings, ...s.warnings]));
277
+ if (s.probeMode === 'deep')
278
+ existing.probeMode = 'deep';
200
279
  }
201
280
  return [...byName.values()];
202
281
  }
@@ -207,14 +286,59 @@ const COLOR = {
207
286
  function riskColor(level) {
208
287
  return level === 'critical' || level === 'high' ? COLOR.red : level === 'medium' ? COLOR.yellow : COLOR.green;
209
288
  }
210
- async function upload(servers, apiUrl, apiKey, connectorName) {
289
+ function applyProxyClassification(servers, scanned, cwd) {
290
+ const wrapsByConfig = (0, discoverProxy_1.buildGatewayWrapIndex)(servers);
291
+ for (const server of servers) {
292
+ server.proxyStatus = (0, discoverProxy_1.classifyProxyStatus)(server, wrapsByConfig);
293
+ if (server.proxyStatus === 'fcd_gateway') {
294
+ server.wrapsServerName = (0, discoverProxy_1.extractGatewayDownstream)(server)?.label;
295
+ }
296
+ if (server.proxyStatus === 'direct' && server.riskLevel !== 'low') {
297
+ server.warnings.push('Direct MCP — not proxied through Full Court Defense gateway.');
298
+ }
299
+ const info = (0, knownMcpServers_1.knownMcpServerInfo)(server.serverName, server.command);
300
+ if (info) {
301
+ server.serverKind = info.category;
302
+ server.serverDescription = info.description;
303
+ }
304
+ if (server.proxyStatus === 'direct') {
305
+ const hint = (0, knownMcpServers_1.directServerFixHint)(server.serverName);
306
+ if (hint && !server.warnings.includes(hint))
307
+ server.warnings.push(hint);
308
+ }
309
+ }
310
+ return (0, discoverProxy_1.buildClientCoverage)(scanned, servers, cwd);
311
+ }
312
+ function proxyStatusLabel(status) {
313
+ if (status === 'fcd_gateway')
314
+ return 'FCD gateway';
315
+ if (status === 'proxied')
316
+ return 'Proxied';
317
+ if (status === 'direct')
318
+ return 'Direct — needs gateway';
319
+ return 'Unknown';
320
+ }
321
+ async function upload(servers, host, clientCoverage, apiUrl, apiKey, connectorName) {
211
322
  const payload = {
212
- connectorName: connectorName || 'Local MCP discovery (CLI)',
323
+ connectorName: connectorName || `Desktop MCP · ${host.hostname}`,
324
+ host,
325
+ clientCoverage,
213
326
  servers: servers.map(s => ({
214
327
  serverName: s.serverName,
215
328
  command: s.command,
216
329
  url: s.url,
217
- tools: s.tools.map(name => ({ name })),
330
+ transport: s.transport,
331
+ clientSource: s.source,
332
+ configPath: s.configPath,
333
+ riskLevel: s.riskLevel,
334
+ riskTags: s.riskTags,
335
+ warnings: s.warnings,
336
+ probeMode: s.probeMode,
337
+ proxyStatus: s.proxyStatus,
338
+ wrapsServerName: s.wrapsServerName,
339
+ serverKind: s.serverKind,
340
+ serverDescription: s.serverDescription,
341
+ tools: s.tools,
218
342
  })),
219
343
  };
220
344
  const resp = await fetch(`${apiUrl.replace(/\/$/, '')}/api/cicd/discovery/mcp`, {
@@ -227,12 +351,30 @@ async function upload(servers, apiUrl, apiKey, connectorName) {
227
351
  throw new Error(data.error || `Upload failed (${resp.status})`);
228
352
  }
229
353
  const ingested = data.data?.ingested ?? servers.length;
230
- console.log(`${COLOR.green}Uploaded ${ingested} MCP server(s) to your AI Inventory.${COLOR.reset}`);
354
+ console.log(`${COLOR.green}Uploaded ${ingested} MCP server(s) from ${host.hostname} to your AI Inventory.${COLOR.reset}`);
231
355
  }
232
356
  async function discoverCommand(args, config) {
233
357
  const type = (args.type || 'mcp').toLowerCase();
358
+ const silent = args.silent === 'true';
359
+ const deep = args.deep === 'true';
360
+ if (args.unschedule === 'true') {
361
+ (0, discoverSchedule_1.uninstallDailyDiscoverSchedule)();
362
+ if (!silent)
363
+ console.log('Removed daily desktop discovery schedule.');
364
+ return;
365
+ }
366
+ if (args.schedule === 'daily') {
367
+ const hour = args.scheduleHour ? Number.parseInt(args.scheduleHour, 10) : 9;
368
+ (0, discoverSchedule_1.installDailyDiscoverSchedule)({ userEmail: args.userEmail, hour: Number.isFinite(hour) ? hour : 9 });
369
+ if (!silent) {
370
+ console.log(`Daily desktop discovery scheduled (runs discover --upload --silent${args.userEmail ? ` --user-email ${args.userEmail}` : ''}).`);
371
+ console.log('Requires FULLCOURTDEFENSE_API_KEY or apiKey in ~/.fullcourtdefense.yml');
372
+ }
373
+ return;
374
+ }
234
375
  if (type !== 'mcp') {
235
- console.error(`Unsupported discover type "${type}". Supported: mcp`);
376
+ if (!silent)
377
+ console.error(`Unsupported discover type "${type}". Supported: mcp`);
236
378
  process.exit(1);
237
379
  }
238
380
  const cwd = process.cwd();
@@ -240,29 +382,84 @@ async function discoverCommand(args, config) {
240
382
  const scanned = [];
241
383
  let found = [];
242
384
  for (const c of candidates) {
243
- if (!fs.existsSync(c.path))
244
- continue;
245
- scanned.push(`${c.source} → ${c.path}`);
246
- found.push(...parseConfigFile(c.path, c.source));
385
+ scanned.push({ path: c.path, source: c.source });
386
+ if (fs.existsSync(c.path)) {
387
+ found.push(...parseConfigFile(c.path, c.source));
388
+ }
247
389
  }
248
390
  found = dedupe(found);
391
+ const clientCoverage = applyProxyClassification(found, scanned, cwd);
392
+ if (deep && found.length > 0) {
393
+ if (!silent)
394
+ console.log(`${COLOR.gray}Running deep probe (HTTP/SSE tools/list)…${COLOR.reset}`);
395
+ await enrichWithDeepProbe(found, silent);
396
+ }
397
+ const host = buildHostMetadata(args.userEmail, deep && found.some(s => s.probeMode === 'deep') ? 'deep' : 'config');
249
398
  if (args.json === 'true') {
250
- console.log(JSON.stringify({ scannedFiles: scanned, servers: found }, null, 2));
399
+ console.log(JSON.stringify({
400
+ host,
401
+ scannedFiles: scanned.map(s => `${s.source} → ${s.path}`),
402
+ clientCoverage,
403
+ servers: found,
404
+ }, null, 2));
405
+ return;
406
+ }
407
+ if (silent) {
408
+ if (args.upload === 'true') {
409
+ const apiKey = args.apiKey || config.apiKey || process.env.FULLCOURTDEFENSE_API_KEY;
410
+ const apiUrl = args.apiUrl || config.apiUrl || DEFAULT_API_URL;
411
+ if (!apiKey)
412
+ process.exit(1);
413
+ if (scanned.length === 0)
414
+ return;
415
+ await upload(found, host, clientCoverage, apiUrl, apiKey, args.connectorName);
416
+ }
251
417
  return;
252
418
  }
253
419
  console.log(`\n${COLOR.bold}${COLOR.cyan}MCP server discovery${COLOR.reset}`);
420
+ console.log(`${COLOR.gray}Host ${host.hostname} · ${host.platform} · machine ${host.machineId}${COLOR.reset}`);
254
421
  console.log(`${COLOR.gray}Scanned ${candidates.length} known config locations on this machine.${COLOR.reset}\n`);
255
422
  if (scanned.length === 0) {
256
423
  console.log(`${COLOR.yellow}No MCP config files found.${COLOR.reset}`);
257
- console.log(`${COLOR.gray}Looked in Cursor, Claude Desktop, VS Code, Windsurf, and repo-root configs.${COLOR.reset}`);
424
+ console.log(`${COLOR.gray}Looked in Cursor, Claude Desktop/Code, VS Code, Windsurf, Codex, Gemini CLI, and repo-root configs.${COLOR.reset}`);
258
425
  return;
259
426
  }
260
- console.log(`${COLOR.bold}Config files found:${COLOR.reset}`);
261
- for (const s of scanned)
262
- console.log(` ${COLOR.dim}•${COLOR.reset} ${s}`);
427
+ console.log(`${COLOR.bold}AI clients checked (${scanned.length}):${COLOR.reset}`);
428
+ for (const s of scanned) {
429
+ const exists = fs.existsSync(s.path);
430
+ console.log(` ${COLOR.dim}•${COLOR.reset} ${s.source} → ${s.path}${exists ? '' : ` ${COLOR.yellow}(not created yet)${COLOR.reset}`}`);
431
+ }
263
432
  console.log('');
433
+ if (clientCoverage.length > 0) {
434
+ console.log(`${COLOR.bold}Client coverage:${COLOR.reset}`);
435
+ for (const row of clientCoverage) {
436
+ const gateway = row.mcpGatewayInstalled ? `${COLOR.green}gateway ✓${COLOR.reset}` : `${COLOR.red}no gateway${COLOR.reset}`;
437
+ const hooks = row.clientKey === 'cursor'
438
+ ? (row.cursorHooksInstalled ? `${COLOR.green}hooks ✓ (${row.cursorHookEvents.join(', ')})${COLOR.reset}` : `${COLOR.yellow}no hooks${COLOR.reset}`)
439
+ : '';
440
+ console.log(` ${COLOR.dim}•${COLOR.reset} ${row.client} — ${row.mcpServerCount} MCP · ${gateway}${hooks ? ` · ${hooks}` : ''}`);
441
+ if (row.clientKey === 'claude_desktop' && !row.mcpGatewayInstalled && row.mcpServerCount === 0) {
442
+ console.log(` ${COLOR.gray}Install: fullcourtdefense install-claude-desktop-mcp-gateway --shield-id ... --mcp-command npm --mcp-args "run mcp"${COLOR.reset}`);
443
+ }
444
+ }
445
+ console.log('');
446
+ }
264
447
  if (found.length === 0) {
265
- console.log(`${COLOR.yellow}Config files exist but declare no MCP servers.${COLOR.reset}`);
448
+ console.log(`${COLOR.yellow}No MCP servers configured in these clients yet.${COLOR.reset}`);
449
+ console.log(`${COLOR.gray}Install gateway everywhere: fullcourtdefense install-mcp-gateway --clients all --shield-id ... --shield-key ... --mcp-command npm --mcp-args "run mcp"${COLOR.reset}`);
450
+ if (args.upload === 'true') {
451
+ const apiKey = args.apiKey || config.apiKey || process.env.FULLCOURTDEFENSE_API_KEY;
452
+ const apiUrl = args.apiUrl || config.apiUrl || DEFAULT_API_URL;
453
+ if (!apiKey) {
454
+ console.error(`\n${COLOR.red}--upload requires an API key.${COLOR.reset}`);
455
+ process.exit(1);
456
+ }
457
+ console.log('');
458
+ await upload(found, host, clientCoverage, apiUrl, apiKey, args.connectorName);
459
+ }
460
+ else {
461
+ console.log(`${COLOR.gray}Run with --upload to push client coverage into AI Inventory.${COLOR.reset}`);
462
+ }
266
463
  return;
267
464
  }
268
465
  const order = { critical: 0, high: 1, medium: 2, low: 3 };
@@ -271,12 +468,17 @@ async function discoverCommand(args, config) {
271
468
  for (const s of found) {
272
469
  const col = riskColor(s.riskLevel);
273
470
  const target = s.command ? `${s.command} ${(s.args || []).join(' ')}`.trim() : (s.url || 'unknown');
274
- console.log(` ${col}${COLOR.bold}[${s.riskLevel.toUpperCase()}]${COLOR.reset} ${COLOR.bold}${s.serverName}${COLOR.reset} ${COLOR.gray}(${s.transport})${COLOR.reset}`);
471
+ const proxyCol = s.proxyStatus === 'direct' ? COLOR.red : s.proxyStatus === 'proxied' || s.proxyStatus === 'fcd_gateway' ? COLOR.green : COLOR.yellow;
472
+ console.log(` ${col}${COLOR.bold}[${s.riskLevel.toUpperCase()}]${COLOR.reset} ${COLOR.bold}${s.serverName}${COLOR.reset} ${COLOR.gray}(${s.transport}${s.probeMode === 'deep' ? ', deep' : ''})${COLOR.reset} ${proxyCol}${proxyStatusLabel(s.proxyStatus)}${COLOR.reset}`);
473
+ if (s.serverDescription)
474
+ console.log(` ${COLOR.gray}about:${COLOR.reset} ${s.serverDescription}`);
275
475
  console.log(` ${COLOR.gray}target:${COLOR.reset} ${target}`);
276
476
  if (s.riskTags.length)
277
477
  console.log(` ${COLOR.gray}tags:${COLOR.reset} ${s.riskTags.join(', ')}`);
278
- if (s.tools.length)
279
- console.log(` ${COLOR.gray}tools:${COLOR.reset} ${s.tools.slice(0, 12).join(', ')}${s.tools.length > 12 ? ' …' : ''}`);
478
+ if (s.tools.length) {
479
+ const names = s.tools.map(t => t.name);
480
+ console.log(` ${COLOR.gray}tools:${COLOR.reset} ${names.slice(0, 12).join(', ')}${names.length > 12 ? ' …' : ''}`);
481
+ }
280
482
  console.log(` ${COLOR.gray}from:${COLOR.reset} ${s.source}`);
281
483
  for (const w of s.warnings)
282
484
  console.log(` ${COLOR.red}⚠ ${w}${COLOR.reset}`);
@@ -292,9 +494,24 @@ async function discoverCommand(args, config) {
292
494
  process.exit(1);
293
495
  }
294
496
  console.log('');
295
- await upload(found, apiUrl, apiKey, args.connectorName);
497
+ await upload(found, host, clientCoverage, apiUrl, apiKey, args.connectorName);
296
498
  }
297
499
  else {
298
500
  console.log(`${COLOR.gray}Run with --upload to push these into your AI Inventory.${COLOR.reset}`);
501
+ if (!deep)
502
+ console.log(`${COLOR.gray}Add --deep to live-probe HTTP/SSE servers for tools/list.${COLOR.reset}`);
503
+ console.log(`${COLOR.gray}Add --schedule daily for automatic daily upload (MDM/fleet).${COLOR.reset}`);
299
504
  }
300
505
  }
506
+ /** Upload-only discover — used after install-mcp-gateway --upload. */
507
+ async function runDiscoverUpload(args, config) {
508
+ await discoverCommand({
509
+ type: 'mcp',
510
+ upload: 'true',
511
+ silent: args.silent ? 'true' : undefined,
512
+ apiKey: args.apiKey,
513
+ apiUrl: args.apiUrl,
514
+ userEmail: args.userEmail,
515
+ connectorName: args.connectorName,
516
+ }, config);
517
+ }
@@ -0,0 +1,18 @@
1
+ export interface ConfigPathCandidate {
2
+ path: string;
3
+ source: string;
4
+ clientKey: string;
5
+ }
6
+ export interface ClaudeDesktopConfigCandidate {
7
+ file: string;
8
+ source: string;
9
+ exists: boolean;
10
+ }
11
+ export declare function claudeDesktopMsixCandidates(): ClaudeDesktopConfigCandidate[];
12
+ export declare function claudeDesktopConfigCandidates(): ClaudeDesktopConfigCandidate[];
13
+ export declare function claudeDesktopLikelyInstalled(): boolean;
14
+ export declare function claudeDesktopInstallTargets(configPath?: string): ClaudeDesktopConfigCandidate[];
15
+ /** All MCP client config locations discover / install should check. */
16
+ export declare function candidateConfigPaths(cwd: string, extra?: string): ConfigPathCandidate[];
17
+ /** Include config paths for apps that exist on disk even when the MCP file is not created yet. */
18
+ export declare function discoverScanTargets(cwd: string, extra?: string): ConfigPathCandidate[];