@bryan-thompson/inspector-assessment-cli 1.26.4 → 1.26.6

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.
@@ -0,0 +1,746 @@
1
+ /**
2
+ * Assessment Runner Module
3
+ *
4
+ * Handles MCP server connection, assessment orchestration, and configuration
5
+ * for the mcp-assess-full CLI tool.
6
+ *
7
+ * Extracted from assess-full.ts as part of Issue #90 modularization.
8
+ *
9
+ * @module cli/lib/assessment-runner
10
+ */
11
+ import * as fs from "fs";
12
+ import * as path from "path";
13
+ import * as os from "os";
14
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
15
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
16
+ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
17
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
18
+ import { AssessmentOrchestrator, } from "../../../client/lib/services/assessment/AssessmentOrchestrator.js";
19
+ import { DEFAULT_ASSESSMENT_CONFIG, getAllModulesConfig, } from "../../../client/lib/lib/assessmentTypes.js";
20
+ import { FULL_CLAUDE_CODE_CONFIG } from "../../../client/lib/services/assessment/lib/claudeCodeBridge.js";
21
+ import { loadPerformanceConfig } from "../../../client/lib/services/assessment/config/performanceConfig.js";
22
+ import { AssessmentStateManager } from "../assessmentState.js";
23
+ import { emitServerConnected, emitToolDiscovered, emitToolsDiscoveryComplete, emitAssessmentComplete, emitTestBatch, emitVulnerabilityFound, emitAnnotationMissing, emitAnnotationMisaligned, emitAnnotationReviewRecommended, emitAnnotationAligned, emitModulesConfigured, } from "./jsonl-events.js";
24
+ import { getProfileModules, resolveModuleNames, modulesToLegacyConfig, } from "../profiles.js";
25
+ // ============================================================================
26
+ // Server Configuration
27
+ // ============================================================================
28
+ /**
29
+ * Load server configuration from Claude Code's MCP settings
30
+ *
31
+ * @param serverName - Name of the server to look up
32
+ * @param configPath - Optional explicit config path
33
+ * @returns Server configuration object
34
+ */
35
+ export function loadServerConfig(serverName, configPath) {
36
+ const possiblePaths = [
37
+ configPath,
38
+ path.join(os.homedir(), ".config", "mcp", "servers", `${serverName}.json`),
39
+ path.join(os.homedir(), ".config", "claude", "claude_desktop_config.json"),
40
+ ].filter(Boolean);
41
+ for (const tryPath of possiblePaths) {
42
+ if (!fs.existsSync(tryPath))
43
+ continue;
44
+ const config = JSON.parse(fs.readFileSync(tryPath, "utf-8"));
45
+ if (config.mcpServers && config.mcpServers[serverName]) {
46
+ const serverConfig = config.mcpServers[serverName];
47
+ // Check if serverConfig specifies http/sse transport
48
+ if (serverConfig.url ||
49
+ serverConfig.transport === "http" ||
50
+ serverConfig.transport === "sse") {
51
+ if (!serverConfig.url) {
52
+ throw new Error(`Invalid server config: transport is '${serverConfig.transport}' but 'url' is missing`);
53
+ }
54
+ return {
55
+ transport: serverConfig.transport || "http",
56
+ url: serverConfig.url,
57
+ };
58
+ }
59
+ // Default to stdio transport
60
+ return {
61
+ transport: "stdio",
62
+ command: serverConfig.command,
63
+ args: serverConfig.args || [],
64
+ env: serverConfig.env || {},
65
+ cwd: serverConfig.cwd,
66
+ };
67
+ }
68
+ if (config.url ||
69
+ config.transport === "http" ||
70
+ config.transport === "sse") {
71
+ if (!config.url) {
72
+ throw new Error(`Invalid server config: transport is '${config.transport}' but 'url' is missing`);
73
+ }
74
+ return {
75
+ transport: config.transport || "http",
76
+ url: config.url,
77
+ };
78
+ }
79
+ if (config.command) {
80
+ return {
81
+ transport: "stdio",
82
+ command: config.command,
83
+ args: config.args || [],
84
+ env: config.env || {},
85
+ };
86
+ }
87
+ }
88
+ throw new Error(`Server config not found for: ${serverName}\nTried: ${possiblePaths.join(", ")}`);
89
+ }
90
+ // ============================================================================
91
+ // Source File Loading
92
+ // ============================================================================
93
+ /**
94
+ * Load optional files from source code path
95
+ *
96
+ * @param sourcePath - Path to source code directory
97
+ * @returns Object containing loaded source files
98
+ */
99
+ export function loadSourceFiles(sourcePath) {
100
+ const result = {};
101
+ // Search for README in source directory and parent directories (up to 3 levels)
102
+ // This handles cases where --source points to a subdirectory but README is at repo root
103
+ const readmePaths = ["README.md", "readme.md", "Readme.md"];
104
+ let readmeFound = false;
105
+ // First try the source directory itself
106
+ for (const readmePath of readmePaths) {
107
+ const fullPath = path.join(sourcePath, readmePath);
108
+ if (fs.existsSync(fullPath)) {
109
+ result.readmeContent = fs.readFileSync(fullPath, "utf-8");
110
+ readmeFound = true;
111
+ break;
112
+ }
113
+ }
114
+ // If not found, search parent directories (up to 3 levels)
115
+ if (!readmeFound) {
116
+ let currentDir = sourcePath;
117
+ for (let i = 0; i < 3; i++) {
118
+ const parentDir = path.dirname(currentDir);
119
+ if (parentDir === currentDir)
120
+ break; // Reached filesystem root
121
+ for (const readmePath of readmePaths) {
122
+ const fullPath = path.join(parentDir, readmePath);
123
+ if (fs.existsSync(fullPath)) {
124
+ result.readmeContent = fs.readFileSync(fullPath, "utf-8");
125
+ readmeFound = true;
126
+ break;
127
+ }
128
+ }
129
+ if (readmeFound)
130
+ break;
131
+ currentDir = parentDir;
132
+ }
133
+ }
134
+ const packagePath = path.join(sourcePath, "package.json");
135
+ if (fs.existsSync(packagePath)) {
136
+ result.packageJson = JSON.parse(fs.readFileSync(packagePath, "utf-8"));
137
+ }
138
+ const manifestPath = path.join(sourcePath, "manifest.json");
139
+ if (fs.existsSync(manifestPath)) {
140
+ result.manifestRaw = fs.readFileSync(manifestPath, "utf-8");
141
+ try {
142
+ result.manifestJson = JSON.parse(result.manifestRaw);
143
+ }
144
+ catch {
145
+ console.warn("[Assessment] Failed to parse manifest.json");
146
+ }
147
+ }
148
+ result.sourceCodeFiles = new Map();
149
+ // Include config files for portability analysis
150
+ const sourceExtensions = [
151
+ ".ts",
152
+ ".js",
153
+ ".py",
154
+ ".go",
155
+ ".rs",
156
+ ".json",
157
+ ".sh",
158
+ ".yaml",
159
+ ".yml",
160
+ ];
161
+ // Parse .gitignore patterns
162
+ const gitignorePatterns = [];
163
+ const gitignorePath = path.join(sourcePath, ".gitignore");
164
+ if (fs.existsSync(gitignorePath)) {
165
+ const gitignoreContent = fs.readFileSync(gitignorePath, "utf-8");
166
+ for (const line of gitignoreContent.split("\n")) {
167
+ const trimmed = line.trim();
168
+ if (!trimmed || trimmed.startsWith("#"))
169
+ continue;
170
+ // Convert gitignore pattern to regex
171
+ const pattern = trimmed
172
+ .replace(/\./g, "\\.")
173
+ .replace(/\*\*/g, ".*")
174
+ .replace(/\*/g, "[^/]*")
175
+ .replace(/\?/g, ".");
176
+ try {
177
+ gitignorePatterns.push(new RegExp(pattern));
178
+ }
179
+ catch {
180
+ // Skip invalid patterns
181
+ }
182
+ }
183
+ }
184
+ const isGitignored = (relativePath) => {
185
+ return gitignorePatterns.some((pattern) => pattern.test(relativePath));
186
+ };
187
+ const loadSourceDir = (dir, prefix = "") => {
188
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
189
+ for (const entry of entries) {
190
+ if (entry.name.startsWith(".") || entry.name === "node_modules")
191
+ continue;
192
+ const fullPath = path.join(dir, entry.name);
193
+ const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
194
+ // Skip gitignored files
195
+ if (isGitignored(relativePath))
196
+ continue;
197
+ if (entry.isDirectory()) {
198
+ loadSourceDir(fullPath, relativePath);
199
+ }
200
+ else if (sourceExtensions.some((ext) => entry.name.endsWith(ext))) {
201
+ try {
202
+ const content = fs.readFileSync(fullPath, "utf-8");
203
+ if (content.length < 100000) {
204
+ result.sourceCodeFiles.set(relativePath, content);
205
+ }
206
+ }
207
+ catch {
208
+ // Skip unreadable files
209
+ }
210
+ }
211
+ }
212
+ };
213
+ try {
214
+ loadSourceDir(sourcePath);
215
+ }
216
+ catch (e) {
217
+ console.warn("[Assessment] Could not load source files:", e);
218
+ }
219
+ return result;
220
+ }
221
+ // ============================================================================
222
+ // Server Connection
223
+ // ============================================================================
224
+ /**
225
+ * Connect to MCP server via configured transport
226
+ *
227
+ * @param config - Server configuration
228
+ * @returns Connected MCP client
229
+ */
230
+ export async function connectToServer(config) {
231
+ let transport;
232
+ let stderrData = ""; // Capture stderr for error reporting
233
+ switch (config.transport) {
234
+ case "http":
235
+ if (!config.url)
236
+ throw new Error("URL required for HTTP transport");
237
+ transport = new StreamableHTTPClientTransport(new URL(config.url));
238
+ break;
239
+ case "sse":
240
+ if (!config.url)
241
+ throw new Error("URL required for SSE transport");
242
+ transport = new SSEClientTransport(new URL(config.url));
243
+ break;
244
+ case "stdio":
245
+ default:
246
+ if (!config.command)
247
+ throw new Error("Command required for stdio transport");
248
+ transport = new StdioClientTransport({
249
+ command: config.command,
250
+ args: config.args,
251
+ env: {
252
+ ...Object.fromEntries(Object.entries(process.env).filter(([, v]) => v !== undefined)),
253
+ ...config.env,
254
+ },
255
+ cwd: config.cwd,
256
+ stderr: "pipe",
257
+ });
258
+ // Capture stderr BEFORE connecting - critical for error context
259
+ // The MCP SDK creates a PassThrough stream immediately when stderr: "pipe"
260
+ // is set, allowing us to attach listeners before start() is called
261
+ const stderrStream = transport.stderr;
262
+ if (stderrStream) {
263
+ stderrStream.on("data", (data) => {
264
+ stderrData += data.toString();
265
+ });
266
+ }
267
+ break;
268
+ }
269
+ const client = new Client({
270
+ name: "mcp-assess-full",
271
+ version: "1.0.0",
272
+ }, {
273
+ capabilities: {},
274
+ });
275
+ try {
276
+ await client.connect(transport);
277
+ }
278
+ catch (error) {
279
+ const errorMessage = error instanceof Error ? error.message : String(error);
280
+ // Provide helpful context when connection fails
281
+ if (stderrData.trim()) {
282
+ throw new Error(`Failed to connect to MCP server: ${errorMessage}\n\n` +
283
+ `Server stderr:\n${stderrData.trim()}\n\n` +
284
+ `Common causes:\n` +
285
+ ` - Missing environment variables (check .env file)\n` +
286
+ ` - Required external services not running\n` +
287
+ ` - Missing API credentials`);
288
+ }
289
+ throw new Error(`Failed to connect to MCP server: ${errorMessage}`);
290
+ }
291
+ return client;
292
+ }
293
+ // ============================================================================
294
+ // Tool Call Wrapper
295
+ // ============================================================================
296
+ /**
297
+ * Create callTool wrapper for assessment context
298
+ *
299
+ * @param client - Connected MCP client
300
+ * @returns Wrapped callTool function
301
+ */
302
+ export function createCallToolWrapper(client) {
303
+ return async (name, params) => {
304
+ try {
305
+ const response = await client.callTool({
306
+ name,
307
+ arguments: params,
308
+ });
309
+ return {
310
+ content: response.content,
311
+ isError: response.isError || false,
312
+ structuredContent: response
313
+ .structuredContent,
314
+ };
315
+ }
316
+ catch (error) {
317
+ return {
318
+ content: [
319
+ {
320
+ type: "text",
321
+ text: `Error: ${error instanceof Error ? error.message : String(error)}`,
322
+ },
323
+ ],
324
+ isError: true,
325
+ };
326
+ }
327
+ };
328
+ }
329
+ // ============================================================================
330
+ // Configuration Building
331
+ // ============================================================================
332
+ /**
333
+ * Build assessment configuration from CLI options
334
+ *
335
+ * @param options - CLI assessment options
336
+ * @returns Assessment configuration
337
+ */
338
+ export function buildConfig(options) {
339
+ const config = {
340
+ ...DEFAULT_ASSESSMENT_CONFIG,
341
+ enableExtendedAssessment: options.fullAssessment !== false,
342
+ parallelTesting: true,
343
+ testTimeout: 30000,
344
+ enableSourceCodeAnalysis: Boolean(options.sourceCodePath),
345
+ };
346
+ if (options.fullAssessment !== false) {
347
+ // Priority: --profile > --only-modules > --skip-modules > default (all)
348
+ if (options.profile) {
349
+ // Use profile-based module selection
350
+ const profileModules = getProfileModules(options.profile, {
351
+ hasSourceCode: Boolean(options.sourceCodePath),
352
+ skipTemporal: options.skipTemporal,
353
+ });
354
+ // Convert new-style module list to legacy config format
355
+ // (until orchestrator is updated to use new naming)
356
+ config.assessmentCategories = modulesToLegacyConfig(profileModules);
357
+ }
358
+ else {
359
+ // Derive module config from ASSESSMENT_CATEGORY_METADATA (single source of truth)
360
+ const allModules = getAllModulesConfig({
361
+ sourceCodePath: Boolean(options.sourceCodePath),
362
+ skipTemporal: options.skipTemporal,
363
+ });
364
+ // Apply --only-modules filter (whitelist mode)
365
+ if (options.onlyModules?.length) {
366
+ // Resolve any deprecated module names
367
+ const resolved = resolveModuleNames(options.onlyModules);
368
+ for (const key of Object.keys(allModules)) {
369
+ // Disable all modules except those in the whitelist
370
+ allModules[key] = resolved.includes(key);
371
+ }
372
+ }
373
+ // Apply --skip-modules filter (blacklist mode)
374
+ if (options.skipModules?.length) {
375
+ // Resolve any deprecated module names
376
+ const resolved = resolveModuleNames(options.skipModules);
377
+ for (const module of resolved) {
378
+ if (module in allModules) {
379
+ allModules[module] = false;
380
+ }
381
+ }
382
+ }
383
+ config.assessmentCategories =
384
+ allModules;
385
+ }
386
+ }
387
+ // Temporal/rug pull detection configuration
388
+ if (options.temporalInvocations) {
389
+ config.temporalInvocations = options.temporalInvocations;
390
+ }
391
+ if (options.claudeEnabled) {
392
+ // Check for HTTP transport via --claude-http flag or environment variables
393
+ const useHttpTransport = options.claudeHttp || process.env.INSPECTOR_CLAUDE === "true";
394
+ const auditorUrl = options.mcpAuditorUrl ||
395
+ process.env.INSPECTOR_MCP_AUDITOR_URL ||
396
+ "http://localhost:8085";
397
+ config.claudeCode = {
398
+ enabled: true,
399
+ timeout: FULL_CLAUDE_CODE_CONFIG.timeout || 60000,
400
+ maxRetries: FULL_CLAUDE_CODE_CONFIG.maxRetries || 2,
401
+ // Use HTTP transport when --claude-http flag or INSPECTOR_CLAUDE env is set
402
+ ...(useHttpTransport && {
403
+ transport: "http",
404
+ httpConfig: {
405
+ baseUrl: auditorUrl,
406
+ },
407
+ }),
408
+ features: {
409
+ intelligentTestGeneration: true,
410
+ aupSemanticAnalysis: true,
411
+ annotationInference: true,
412
+ documentationQuality: true,
413
+ },
414
+ };
415
+ if (useHttpTransport) {
416
+ console.log(`🔗 Claude Bridge HTTP transport: ${auditorUrl}`);
417
+ }
418
+ }
419
+ // Pass custom annotation pattern config path
420
+ if (options.patternConfigPath) {
421
+ config.patternConfigPath = options.patternConfigPath;
422
+ }
423
+ // Load custom performance config if provided (Issue #37)
424
+ // Note: Currently, modules use DEFAULT_PERFORMANCE_CONFIG directly.
425
+ // This validates the config file but doesn't override runtime values yet.
426
+ // Future enhancement: Pass performanceConfig through AssessmentContext.
427
+ if (options.performanceConfigPath) {
428
+ try {
429
+ const performanceConfig = loadPerformanceConfig(options.performanceConfigPath);
430
+ console.log(`📊 Performance config loaded from: ${options.performanceConfigPath}`);
431
+ console.log(` Batch interval: ${performanceConfig.batchFlushIntervalMs}ms, ` +
432
+ `Security batch: ${performanceConfig.securityBatchSize}, ` +
433
+ `Functionality batch: ${performanceConfig.functionalityBatchSize}`);
434
+ // TODO: Wire performanceConfig through AssessmentContext to modules
435
+ }
436
+ catch (error) {
437
+ console.error(`❌ Failed to load performance config: ${error instanceof Error ? error.message : String(error)}`);
438
+ throw error;
439
+ }
440
+ }
441
+ // Logging configuration
442
+ // Precedence: CLI flags > LOG_LEVEL env var > default (info)
443
+ const envLogLevel = process.env.LOG_LEVEL;
444
+ const logLevel = options.logLevel ?? envLogLevel ?? "info";
445
+ config.logging = { level: logLevel };
446
+ return config;
447
+ }
448
+ // ============================================================================
449
+ // Full Assessment Orchestration
450
+ // ============================================================================
451
+ /**
452
+ * Run full assessment against an MCP server
453
+ *
454
+ * @param options - CLI assessment options
455
+ * @returns Assessment results
456
+ */
457
+ export async function runFullAssessment(options) {
458
+ if (!options.jsonOnly) {
459
+ console.log(`\n🔍 Starting full assessment for: ${options.serverName}`);
460
+ }
461
+ const serverConfig = loadServerConfig(options.serverName, options.serverConfigPath);
462
+ if (!options.jsonOnly) {
463
+ console.log("✅ Server config loaded");
464
+ }
465
+ const client = await connectToServer(serverConfig);
466
+ emitServerConnected(options.serverName, serverConfig.transport || "stdio");
467
+ if (!options.jsonOnly) {
468
+ console.log("✅ Connected to MCP server");
469
+ }
470
+ // Capture server info from initialization for protocol conformance checks
471
+ // Apply defensive null checks for protocol conformance validation
472
+ const rawServerInfo = client.getServerVersion();
473
+ const rawServerCapabilities = client.getServerCapabilities();
474
+ // Build serverInfo with safe fallbacks
475
+ const serverInfo = rawServerInfo
476
+ ? {
477
+ name: rawServerInfo.name || "unknown",
478
+ version: rawServerInfo.version,
479
+ metadata: rawServerInfo.metadata,
480
+ }
481
+ : undefined;
482
+ // ServerCapabilities can be undefined - that's valid per MCP spec
483
+ const serverCapabilities = rawServerCapabilities ?? undefined;
484
+ // Log warning if server didn't provide initialization info
485
+ if (!serverInfo && !options.jsonOnly) {
486
+ console.log("⚠️ Server did not provide serverInfo during initialization");
487
+ }
488
+ const response = await client.listTools();
489
+ const tools = response.tools || [];
490
+ // Emit JSONL tool discovery events for audit-worker parsing
491
+ for (const tool of tools) {
492
+ emitToolDiscovered(tool);
493
+ }
494
+ emitToolsDiscoveryComplete(tools.length);
495
+ if (!options.jsonOnly) {
496
+ console.log(`🔧 Found ${tools.length} tool${tools.length !== 1 ? "s" : ""}`);
497
+ }
498
+ // Fetch resources for new capability assessments
499
+ let resources = [];
500
+ let resourceTemplates = [];
501
+ try {
502
+ const resourcesResponse = await client.listResources();
503
+ resources = (resourcesResponse.resources || []).map((r) => ({
504
+ uri: r.uri,
505
+ name: r.name,
506
+ description: r.description,
507
+ mimeType: r.mimeType,
508
+ }));
509
+ // resourceTemplates may be typed as unknown in some SDK versions
510
+ const templates = resourcesResponse.resourceTemplates;
511
+ if (templates) {
512
+ resourceTemplates = templates.map((rt) => ({
513
+ uriTemplate: rt.uriTemplate,
514
+ name: rt.name,
515
+ description: rt.description,
516
+ mimeType: rt.mimeType,
517
+ }));
518
+ }
519
+ if (!options.jsonOnly &&
520
+ (resources.length > 0 || resourceTemplates.length > 0)) {
521
+ console.log(`📦 Found ${resources.length} resource(s) and ${resourceTemplates.length} resource template(s)`);
522
+ }
523
+ }
524
+ catch {
525
+ // Server may not support resources - that's okay
526
+ if (!options.jsonOnly) {
527
+ console.log("📦 Resources not supported by server");
528
+ }
529
+ }
530
+ // Fetch prompts for new capability assessments
531
+ let prompts = [];
532
+ try {
533
+ const promptsResponse = await client.listPrompts();
534
+ prompts = (promptsResponse.prompts || []).map((p) => ({
535
+ name: p.name,
536
+ description: p.description,
537
+ arguments: p.arguments?.map((a) => ({
538
+ name: a.name,
539
+ description: a.description,
540
+ required: a.required,
541
+ })),
542
+ }));
543
+ if (!options.jsonOnly && prompts.length > 0) {
544
+ console.log(`💬 Found ${prompts.length} prompt(s)`);
545
+ }
546
+ }
547
+ catch {
548
+ // Server may not support prompts - that's okay
549
+ if (!options.jsonOnly) {
550
+ console.log("💬 Prompts not supported by server");
551
+ }
552
+ }
553
+ // State management for resumable assessments
554
+ const stateManager = new AssessmentStateManager(options.serverName);
555
+ if (stateManager.exists() && !options.noResume) {
556
+ const summary = stateManager.getSummary();
557
+ if (summary) {
558
+ if (!options.jsonOnly) {
559
+ console.log(`\n📋 Found interrupted session from ${summary.startedAt}`);
560
+ console.log(` Completed modules: ${summary.completedModules.length > 0 ? summary.completedModules.join(", ") : "none"}`);
561
+ }
562
+ if (options.resume) {
563
+ if (!options.jsonOnly) {
564
+ console.log(" Resuming from previous state...");
565
+ }
566
+ // Will use partial results later
567
+ }
568
+ else if (!options.jsonOnly) {
569
+ console.log(" Use --resume to continue or --no-resume to start fresh");
570
+ // Clear state and start fresh by default
571
+ stateManager.clear();
572
+ }
573
+ }
574
+ }
575
+ else if (options.noResume && stateManager.exists()) {
576
+ stateManager.clear();
577
+ if (!options.jsonOnly) {
578
+ console.log("🗑️ Cleared previous assessment state");
579
+ }
580
+ }
581
+ // Pre-flight validation checks
582
+ if (options.preflightOnly) {
583
+ const preflightResult = {
584
+ passed: true,
585
+ toolCount: tools.length,
586
+ errors: [],
587
+ };
588
+ // Check 1: Tools exist
589
+ if (tools.length === 0) {
590
+ preflightResult.passed = false;
591
+ preflightResult.errors.push("No tools discovered from server");
592
+ }
593
+ // Check 2: Manifest valid (if source path provided)
594
+ if (options.sourceCodePath) {
595
+ const manifestPath = path.join(options.sourceCodePath, "manifest.json");
596
+ if (fs.existsSync(manifestPath)) {
597
+ try {
598
+ JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
599
+ preflightResult.manifestValid = true;
600
+ }
601
+ catch {
602
+ preflightResult.passed = false;
603
+ preflightResult.manifestValid = false;
604
+ preflightResult.errors.push("Invalid manifest.json (JSON parse error)");
605
+ }
606
+ }
607
+ }
608
+ // Check 3: First tool responds (basic connectivity)
609
+ if (tools.length > 0) {
610
+ try {
611
+ const callTool = createCallToolWrapper(client);
612
+ const firstToolResult = await callTool(tools[0].name, {});
613
+ preflightResult.serverResponsive = !firstToolResult.isError;
614
+ if (firstToolResult.isError) {
615
+ preflightResult.errors.push(`First tool (${tools[0].name}) returned error - server may not be fully functional`);
616
+ }
617
+ }
618
+ catch (e) {
619
+ preflightResult.serverResponsive = false;
620
+ preflightResult.errors.push(`First tool call failed: ${e instanceof Error ? e.message : String(e)}`);
621
+ }
622
+ }
623
+ await client.close();
624
+ // Output pre-flight result
625
+ console.log(JSON.stringify(preflightResult, null, 2));
626
+ setTimeout(() => process.exit(preflightResult.passed ? 0 : 1), 10);
627
+ // Return empty result (won't be used due to process.exit)
628
+ return {};
629
+ }
630
+ const config = buildConfig(options);
631
+ // Emit modules_configured event for consumer progress tracking
632
+ if (config.assessmentCategories) {
633
+ const enabled = [];
634
+ const skipped = [];
635
+ for (const [key, value] of Object.entries(config.assessmentCategories)) {
636
+ if (value) {
637
+ enabled.push(key);
638
+ }
639
+ else {
640
+ skipped.push(key);
641
+ }
642
+ }
643
+ const reason = options.onlyModules?.length
644
+ ? "only-modules"
645
+ : options.skipModules?.length
646
+ ? "skip-modules"
647
+ : "default";
648
+ emitModulesConfigured(enabled, skipped, reason);
649
+ }
650
+ const orchestrator = new AssessmentOrchestrator(config);
651
+ if (!options.jsonOnly) {
652
+ if (orchestrator.isClaudeEnabled()) {
653
+ console.log("🤖 Claude Code integration enabled");
654
+ }
655
+ else if (options.claudeEnabled) {
656
+ console.log("⚠️ Claude Code requested but not available");
657
+ }
658
+ }
659
+ let sourceFiles = {};
660
+ if (options.sourceCodePath && fs.existsSync(options.sourceCodePath)) {
661
+ sourceFiles = loadSourceFiles(options.sourceCodePath);
662
+ if (!options.jsonOnly) {
663
+ console.log(`📁 Loaded source files from: ${options.sourceCodePath}`);
664
+ }
665
+ }
666
+ // Create readResource wrapper for ResourceAssessor
667
+ const readResource = async (uri) => {
668
+ const response = await client.readResource({ uri });
669
+ // Extract text content from response
670
+ if (response.contents && response.contents.length > 0) {
671
+ const content = response.contents[0];
672
+ if ("text" in content && content.text) {
673
+ return content.text;
674
+ }
675
+ if ("blob" in content && content.blob) {
676
+ // Return base64 blob as string
677
+ return content.blob;
678
+ }
679
+ }
680
+ return "";
681
+ };
682
+ // Create getPrompt wrapper for PromptAssessor
683
+ const getPrompt = async (name, args) => {
684
+ const response = await client.getPrompt({ name, arguments: args });
685
+ return {
686
+ messages: (response.messages || []).map((m) => ({
687
+ role: m.role,
688
+ content: typeof m.content === "string" ? m.content : JSON.stringify(m.content),
689
+ })),
690
+ };
691
+ };
692
+ // Progress callback to emit JSONL events for real-time monitoring
693
+ const onProgress = (event) => {
694
+ if (event.type === "test_batch") {
695
+ emitTestBatch(event.module, event.completed, event.total, event.batchSize, event.elapsed);
696
+ }
697
+ else if (event.type === "vulnerability_found") {
698
+ emitVulnerabilityFound(event.tool, event.pattern, event.confidence, event.evidence, event.riskLevel, event.requiresReview, event.payload);
699
+ }
700
+ else if (event.type === "annotation_missing") {
701
+ emitAnnotationMissing(event.tool, event.title, event.description, event.parameters, event.inferredBehavior);
702
+ }
703
+ else if (event.type === "annotation_misaligned") {
704
+ emitAnnotationMisaligned(event.tool, event.title, event.description, event.parameters, event.field, event.actual, event.expected, event.confidence, event.reason);
705
+ }
706
+ else if (event.type === "annotation_review_recommended") {
707
+ emitAnnotationReviewRecommended(event.tool, event.title, event.description, event.parameters, event.field, event.actual, event.inferred, event.confidence, event.isAmbiguous, event.reason);
708
+ }
709
+ else if (event.type === "annotation_aligned") {
710
+ emitAnnotationAligned(event.tool, event.confidence, event.annotations);
711
+ }
712
+ // module_started and module_complete are handled by orchestrator directly
713
+ };
714
+ const context = {
715
+ serverName: options.serverName,
716
+ tools,
717
+ callTool: createCallToolWrapper(client),
718
+ listTools: async () => {
719
+ const response = await client.listTools();
720
+ return response.tools;
721
+ },
722
+ config,
723
+ sourceCodePath: options.sourceCodePath,
724
+ onProgress,
725
+ ...sourceFiles,
726
+ // New capability assessment data
727
+ resources,
728
+ resourceTemplates,
729
+ prompts,
730
+ readResource,
731
+ getPrompt,
732
+ // Server info for protocol conformance checks
733
+ serverInfo,
734
+ serverCapabilities: serverCapabilities,
735
+ };
736
+ if (!options.jsonOnly) {
737
+ console.log(`\n🏃 Running assessment with ${Object.keys(config.assessmentCategories || {}).length} modules...`);
738
+ console.log("");
739
+ }
740
+ const results = await orchestrator.runFullAssessment(context);
741
+ // Emit assessment complete event
742
+ const defaultOutputPath = `/tmp/inspector-full-assessment-${options.serverName}.json`;
743
+ emitAssessmentComplete(results.overallStatus, results.totalTestsRun, results.executionTime, options.outputPath || defaultOutputPath);
744
+ await client.close();
745
+ return results;
746
+ }
@@ -0,0 +1,419 @@
1
+ /**
2
+ * CLI Parser Module
3
+ *
4
+ * Handles command-line argument parsing, validation, and help text for
5
+ * the mcp-assess-full CLI tool.
6
+ *
7
+ * Extracted from assess-full.ts as part of Issue #90 modularization.
8
+ *
9
+ * @module cli/lib/cli-parser
10
+ */
11
+ import { ASSESSMENT_CATEGORY_METADATA, } from "../../../client/lib/lib/assessmentTypes.js";
12
+ import { ASSESSMENT_PROFILES, isValidProfileName, getProfileHelpText, } from "../profiles.js";
13
+ // ============================================================================
14
+ // Constants
15
+ // ============================================================================
16
+ // Valid module names derived from ASSESSMENT_CATEGORY_METADATA
17
+ const VALID_MODULE_NAMES = Object.keys(ASSESSMENT_CATEGORY_METADATA);
18
+ // ============================================================================
19
+ // Validation Functions
20
+ // ============================================================================
21
+ /**
22
+ * Validate module names from CLI input
23
+ *
24
+ * @param input - Comma-separated module names
25
+ * @param flagName - Flag name for error messages (e.g., "--skip-modules")
26
+ * @returns Array of validated module names, or empty array if invalid
27
+ */
28
+ export function validateModuleNames(input, flagName) {
29
+ const names = input
30
+ .split(",")
31
+ .map((n) => n.trim())
32
+ .filter(Boolean);
33
+ const invalid = names.filter((n) => !VALID_MODULE_NAMES.includes(n));
34
+ if (invalid.length > 0) {
35
+ console.error(`Error: Invalid module name(s) for ${flagName}: ${invalid.join(", ")}`);
36
+ console.error(`Valid modules: ${VALID_MODULE_NAMES.join(", ")}`);
37
+ setTimeout(() => process.exit(1), 10);
38
+ return [];
39
+ }
40
+ return names;
41
+ }
42
+ /**
43
+ * Validate parsed options for consistency and requirements
44
+ *
45
+ * @param options - Partial assessment options to validate
46
+ * @returns Validation result with any errors
47
+ */
48
+ export function validateArgs(options) {
49
+ const errors = [];
50
+ // Server name is required
51
+ if (!options.serverName) {
52
+ errors.push("--server is required");
53
+ }
54
+ // Validate mutual exclusivity of --profile, --skip-modules, and --only-modules
55
+ if (options.profile &&
56
+ (options.skipModules?.length || options.onlyModules?.length)) {
57
+ errors.push("--profile cannot be used with --skip-modules or --only-modules");
58
+ }
59
+ if (options.skipModules?.length && options.onlyModules?.length) {
60
+ errors.push("--skip-modules and --only-modules are mutually exclusive");
61
+ }
62
+ return {
63
+ valid: errors.length === 0,
64
+ errors,
65
+ };
66
+ }
67
+ // ============================================================================
68
+ // Argument Parsing
69
+ // ============================================================================
70
+ /**
71
+ * Parse command-line arguments
72
+ *
73
+ * @param argv - Command-line arguments (defaults to process.argv.slice(2))
74
+ * @returns Parsed assessment options
75
+ */
76
+ export function parseArgs(argv) {
77
+ const args = argv ?? process.argv.slice(2);
78
+ const options = {};
79
+ for (let i = 0; i < args.length; i++) {
80
+ const arg = args[i];
81
+ if (!arg)
82
+ continue;
83
+ switch (arg) {
84
+ case "--server":
85
+ case "-s":
86
+ options.serverName = args[++i];
87
+ break;
88
+ case "--config":
89
+ case "-c":
90
+ options.serverConfigPath = args[++i];
91
+ break;
92
+ case "--output":
93
+ case "-o":
94
+ options.outputPath = args[++i];
95
+ break;
96
+ case "--source":
97
+ options.sourceCodePath = args[++i];
98
+ break;
99
+ case "--pattern-config":
100
+ case "-p":
101
+ options.patternConfigPath = args[++i];
102
+ break;
103
+ case "--performance-config":
104
+ options.performanceConfigPath = args[++i];
105
+ break;
106
+ case "--claude-enabled":
107
+ options.claudeEnabled = true;
108
+ break;
109
+ case "--claude-http":
110
+ // Enable Claude Bridge with HTTP transport (connects to mcp-auditor)
111
+ options.claudeEnabled = true;
112
+ options.claudeHttp = true;
113
+ break;
114
+ case "--mcp-auditor-url": {
115
+ const urlValue = args[++i];
116
+ if (!urlValue || urlValue.startsWith("-")) {
117
+ console.error("Error: --mcp-auditor-url requires a URL argument");
118
+ setTimeout(() => process.exit(1), 10);
119
+ options.helpRequested = true;
120
+ return options;
121
+ }
122
+ try {
123
+ new URL(urlValue); // Validate URL format
124
+ options.mcpAuditorUrl = urlValue;
125
+ }
126
+ catch {
127
+ console.error(`Error: Invalid URL for --mcp-auditor-url: ${urlValue}`);
128
+ console.error(" Expected format: http://hostname:port or https://hostname:port");
129
+ setTimeout(() => process.exit(1), 10);
130
+ options.helpRequested = true;
131
+ return options;
132
+ }
133
+ break;
134
+ }
135
+ case "--full":
136
+ options.fullAssessment = true;
137
+ break;
138
+ case "--verbose":
139
+ case "-v":
140
+ options.verbose = true;
141
+ options.logLevel = "debug";
142
+ break;
143
+ case "--silent":
144
+ options.logLevel = "silent";
145
+ break;
146
+ case "--log-level": {
147
+ const levelValue = args[++i];
148
+ const validLevels = [
149
+ "silent",
150
+ "error",
151
+ "warn",
152
+ "info",
153
+ "debug",
154
+ ];
155
+ if (!validLevels.includes(levelValue)) {
156
+ console.error(`Invalid log level: ${levelValue}. Valid options: ${validLevels.join(", ")}`);
157
+ setTimeout(() => process.exit(1), 10);
158
+ options.helpRequested = true;
159
+ return options;
160
+ }
161
+ options.logLevel = levelValue;
162
+ break;
163
+ }
164
+ case "--json":
165
+ options.jsonOnly = true;
166
+ break;
167
+ case "--format":
168
+ case "-f": {
169
+ const formatValue = args[++i];
170
+ if (formatValue !== "json" && formatValue !== "markdown") {
171
+ console.error(`Invalid format: ${formatValue}. Valid options: json, markdown`);
172
+ setTimeout(() => process.exit(1), 10);
173
+ options.helpRequested = true;
174
+ return options;
175
+ }
176
+ options.format = formatValue;
177
+ break;
178
+ }
179
+ case "--include-policy":
180
+ options.includePolicy = true;
181
+ break;
182
+ case "--preflight":
183
+ options.preflightOnly = true;
184
+ break;
185
+ case "--compare":
186
+ options.comparePath = args[++i];
187
+ break;
188
+ case "--diff-only":
189
+ options.diffOnly = true;
190
+ break;
191
+ case "--resume":
192
+ options.resume = true;
193
+ break;
194
+ case "--no-resume":
195
+ options.noResume = true;
196
+ break;
197
+ case "--temporal-invocations":
198
+ options.temporalInvocations = parseInt(args[++i], 10);
199
+ break;
200
+ case "--skip-temporal":
201
+ options.skipTemporal = true;
202
+ break;
203
+ case "--profile": {
204
+ const profileValue = args[++i];
205
+ if (!profileValue) {
206
+ console.error("Error: --profile requires a profile name");
207
+ console.error(`Valid profiles: ${Object.keys(ASSESSMENT_PROFILES).join(", ")}`);
208
+ setTimeout(() => process.exit(1), 10);
209
+ options.helpRequested = true;
210
+ return options;
211
+ }
212
+ if (!isValidProfileName(profileValue)) {
213
+ console.error(`Error: Invalid profile name: ${profileValue}`);
214
+ console.error(`Valid profiles: ${Object.keys(ASSESSMENT_PROFILES).join(", ")}`);
215
+ setTimeout(() => process.exit(1), 10);
216
+ options.helpRequested = true;
217
+ return options;
218
+ }
219
+ options.profile = profileValue;
220
+ break;
221
+ }
222
+ case "--skip-modules": {
223
+ const skipValue = args[++i];
224
+ if (!skipValue) {
225
+ console.error("Error: --skip-modules requires a comma-separated list");
226
+ setTimeout(() => process.exit(1), 10);
227
+ options.helpRequested = true;
228
+ return options;
229
+ }
230
+ options.skipModules = validateModuleNames(skipValue, "--skip-modules");
231
+ if (options.skipModules.length === 0 && skipValue) {
232
+ options.helpRequested = true;
233
+ return options;
234
+ }
235
+ break;
236
+ }
237
+ case "--only-modules": {
238
+ const onlyValue = args[++i];
239
+ if (!onlyValue) {
240
+ console.error("Error: --only-modules requires a comma-separated list");
241
+ setTimeout(() => process.exit(1), 10);
242
+ options.helpRequested = true;
243
+ return options;
244
+ }
245
+ options.onlyModules = validateModuleNames(onlyValue, "--only-modules");
246
+ if (options.onlyModules.length === 0 && onlyValue) {
247
+ options.helpRequested = true;
248
+ return options;
249
+ }
250
+ break;
251
+ }
252
+ case "--help":
253
+ case "-h":
254
+ printHelp();
255
+ options.helpRequested = true;
256
+ return options;
257
+ default:
258
+ if (!arg.startsWith("-")) {
259
+ if (!options.serverName) {
260
+ options.serverName = arg;
261
+ }
262
+ }
263
+ else {
264
+ console.error(`Unknown argument: ${arg}`);
265
+ printHelp();
266
+ setTimeout(() => process.exit(1), 10);
267
+ options.helpRequested = true;
268
+ return options;
269
+ }
270
+ }
271
+ }
272
+ // Validate mutual exclusivity of --profile, --skip-modules, and --only-modules
273
+ if (options.profile &&
274
+ (options.skipModules?.length || options.onlyModules?.length)) {
275
+ console.error("Error: --profile cannot be used with --skip-modules or --only-modules");
276
+ setTimeout(() => process.exit(1), 10);
277
+ options.helpRequested = true;
278
+ return options;
279
+ }
280
+ if (options.skipModules?.length && options.onlyModules?.length) {
281
+ console.error("Error: --skip-modules and --only-modules are mutually exclusive");
282
+ setTimeout(() => process.exit(1), 10);
283
+ options.helpRequested = true;
284
+ return options;
285
+ }
286
+ if (!options.serverName) {
287
+ console.error("Error: --server is required");
288
+ printHelp();
289
+ setTimeout(() => process.exit(1), 10);
290
+ options.helpRequested = true;
291
+ return options;
292
+ }
293
+ // Environment variable fallbacks (matches run-security-assessment.ts behavior)
294
+ // INSPECTOR_CLAUDE=true enables Claude with HTTP transport
295
+ if (process.env.INSPECTOR_CLAUDE === "true" && !options.claudeEnabled) {
296
+ options.claudeEnabled = true;
297
+ options.claudeHttp = true; // HTTP transport when enabled via env var
298
+ }
299
+ // INSPECTOR_MCP_AUDITOR_URL overrides default URL (only if not set via CLI)
300
+ if (process.env.INSPECTOR_MCP_AUDITOR_URL && !options.mcpAuditorUrl) {
301
+ const envUrl = process.env.INSPECTOR_MCP_AUDITOR_URL;
302
+ try {
303
+ new URL(envUrl);
304
+ options.mcpAuditorUrl = envUrl;
305
+ }
306
+ catch {
307
+ console.warn(`Warning: Invalid INSPECTOR_MCP_AUDITOR_URL: ${envUrl}, using default`);
308
+ }
309
+ }
310
+ return options;
311
+ }
312
+ // ============================================================================
313
+ // Help Text
314
+ // ============================================================================
315
+ /**
316
+ * Print help message to console
317
+ */
318
+ export function printHelp() {
319
+ console.log(`
320
+ Usage: mcp-assess-full [options] [server-name]
321
+
322
+ Run comprehensive MCP server assessment with 16 assessor modules organized in 4 tiers.
323
+
324
+ Options:
325
+ --server, -s <name> Server name (required, or pass as first positional arg)
326
+ --config, -c <path> Path to server config JSON
327
+ --output, -o <path> Output path (default: /tmp/inspector-full-assessment-<server>.<ext>)
328
+ --source <path> Source code path for deep analysis (AUP, portability, etc.)
329
+ --pattern-config, -p <path> Path to custom annotation pattern JSON
330
+ --performance-config <path> Path to performance tuning JSON (batch sizes, timeouts, etc.)
331
+ --format, -f <type> Output format: json (default) or markdown
332
+ --include-policy Include policy compliance mapping in report (30 requirements)
333
+ --preflight Run quick validation only (tools exist, manifest valid, server responds)
334
+ --compare <path> Compare current assessment against baseline JSON file
335
+ --diff-only Output only the comparison diff (requires --compare)
336
+ --resume Resume from previous interrupted assessment
337
+ --no-resume Force fresh start, clear any existing state
338
+ --claude-enabled Enable Claude Code integration (CLI transport: requires 'claude' binary)
339
+ --claude-http Enable Claude Code via HTTP transport (connects to mcp-auditor proxy)
340
+ --mcp-auditor-url <url> mcp-auditor URL for HTTP transport (default: http://localhost:8085)
341
+ --full Enable all assessment modules (default)
342
+ --profile <name> Use predefined module profile (quick, security, compliance, full)
343
+ --temporal-invocations <n> Number of invocations per tool for rug pull detection (default: 25)
344
+ --skip-temporal Skip temporal/rug pull testing (faster assessment)
345
+ --skip-modules <list> Skip specific modules (comma-separated)
346
+ --only-modules <list> Run only specific modules (comma-separated)
347
+ --json Output only JSON path (no console summary)
348
+ --verbose, -v Enable verbose logging (same as --log-level debug)
349
+ --silent Suppress all diagnostic logging
350
+ --log-level <level> Set log level: silent, error, warn, info (default), debug
351
+ Also supports LOG_LEVEL environment variable
352
+ --help, -h Show this help message
353
+
354
+ Environment Variables:
355
+ INSPECTOR_CLAUDE=true Enable Claude with HTTP transport (same as --claude-http)
356
+ INSPECTOR_MCP_AUDITOR_URL Override default mcp-auditor URL (default: http://localhost:8085)
357
+ LOG_LEVEL Set log level (overridden by --log-level flag)
358
+
359
+ ${getProfileHelpText()}
360
+ Module Selection:
361
+ --profile, --skip-modules, and --only-modules are mutually exclusive.
362
+ Use --profile for common assessment scenarios.
363
+ Use --skip-modules for custom runs by disabling expensive modules.
364
+ Use --only-modules to focus on specific areas (e.g., tool annotation PRs).
365
+
366
+ Valid module names (new naming):
367
+ functionality, security, errorHandling, protocolCompliance, aupCompliance,
368
+ toolAnnotations, prohibitedLibraries, manifestValidation, authentication,
369
+ temporal, resources, prompts, crossCapability, developerExperience,
370
+ portability, externalAPIScanner
371
+
372
+ Legacy module names (deprecated, will map to new names):
373
+ documentation -> developerExperience
374
+ usability -> developerExperience
375
+ mcpSpecCompliance -> protocolCompliance
376
+ protocolConformance -> protocolCompliance
377
+
378
+ Module Tiers (16 total):
379
+ Tier 1 - Core Security (Always Run):
380
+ • Functionality - Tests all tools work correctly
381
+ • Security - Prompt injection & vulnerability testing
382
+ • Error Handling - Validates error responses
383
+ • Protocol Compliance - MCP protocol + JSON-RPC validation
384
+ • AUP Compliance - Acceptable Use Policy checks
385
+ • Temporal - Rug pull/temporal behavior change detection
386
+
387
+ Tier 2 - Compliance (MCP Directory):
388
+ • Tool Annotations - readOnlyHint/destructiveHint validation
389
+ • Prohibited Libs - Dependency security checks
390
+ • Manifest - MCPB manifest.json validation
391
+ • Authentication - OAuth/auth evaluation
392
+
393
+ Tier 3 - Capability-Based (Conditional):
394
+ • Resources - Resource capability assessment
395
+ • Prompts - Prompt capability assessment
396
+ • Cross-Capability - Chained vulnerability detection
397
+
398
+ Tier 4 - Extended (Optional):
399
+ • Developer Experience - Documentation + usability assessment
400
+ • Portability - Cross-platform compatibility
401
+ • External API - External service detection
402
+
403
+ Examples:
404
+ # Profile-based (recommended):
405
+ mcp-assess-full my-server --profile quick # CI/CD fast check (~30s)
406
+ mcp-assess-full my-server --profile security # Security audit (~2-3min)
407
+ mcp-assess-full my-server --profile compliance # Directory submission (~5min)
408
+ mcp-assess-full my-server --profile full # Comprehensive audit (~10-15min)
409
+
410
+ # Custom module selection:
411
+ mcp-assess-full my-server --skip-modules temporal,resources # Skip expensive modules
412
+ mcp-assess-full my-server --only-modules functionality,toolAnnotations # Annotation PR review
413
+
414
+ # Advanced options:
415
+ mcp-assess-full --server my-server --source ./my-server --output ./results.json
416
+ mcp-assess-full --server my-server --format markdown --include-policy
417
+ mcp-assess-full --server my-server --compare ./baseline.json --diff-only
418
+ `);
419
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bryan-thompson/inspector-assessment-cli",
3
- "version": "1.26.4",
3
+ "version": "1.26.6",
4
4
  "description": "CLI for the Enhanced MCP Inspector with assessment capabilities",
5
5
  "license": "MIT",
6
6
  "author": "Bryan Thompson <bryan@triepod.ai>",