Skip to content

Commit cc704a4

Browse files
ashragrawalclaude
andcommitted
Address PR #362 review: validation, bounded telemetry, IDisposable, env-config, clearer messages, FAQ move
Resolves the remaining review comments (sellakumaran): - [6] --output-dir: validate up front in RunAsync; exit 1 on empty/whitespace instead of a deep exception late in the run (test added). - [3] telemetry: bound LogEvaluateUsageAsync with a 5s linked-CTS timeout so a slow/hung endpoint cannot stall discovery; reword the misleading "fire-and-forget". - [5] auth token: warn when --auth-token is used (visible to ps / shell history) and fall back to A365_MCP_AUTH_TOKEN; document the env var in help. - [8] trust boundary: state it in the evaluate command help (size cap deferred). - [4] SchemaDiscoveryService: implement IDisposable to dispose the owned HttpClient. - [9] model IDs: centralize in EvalModelConstants with A365_EVAL_COPILOT_MODEL / A365_EVAL_CLAUDE_MODEL overrides; drop the "update when newer" comment. - [1b] no-agent message: name only the requested engine (or all, for auto), built from the launcher registry; [1c] log probe failure at Debug and mention -v. - [11] move FAQ.md to docs/agent365-guided-setup/a365-evaluate-faq.md; remove a stale (broken) docs/rai link. Verified by running the evaluate subcommand: empty --output-dir exits 1; a resume run loads the checklist, analyzes, and writes the report (exit 0). Build clean, 281 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b459dda commit cc704a4

11 files changed

Lines changed: 141 additions & 31 deletions

File tree

src/Microsoft.Agents.A365.DevTools.Cli/Services/Evaluate/FAQ.md renamed to docs/agent365-guided-setup/a365-evaluate-faq.md

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,3 @@ Three output files are written to the directory you specify with `--output-dir`:
7171
Temporary files created during the run (the prompt file passed to the coding agent CLI) are deleted automatically when the run finishes or is cancelled. The auth token supplied via `--auth-token` is never written to disk.
7272

7373
If you want to remove all evaluation output, delete the three files above from your output directory. No other local state is created by this command.
74-
75-
---
76-
77-
For the full technical record — threat model, STRIDE analysis, and XPIA mitigation details — see [`docs/rai/`](../../../../docs/rai/README.md).

src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ public static Command CreateCommand(
4747

4848
if (evaluationPipelineService is not null)
4949
{
50-
developMcpCommand.AddCommand(CreateEvaluateSubcommand(evaluationPipelineService));
50+
developMcpCommand.AddCommand(CreateEvaluateSubcommand(logger, evaluationPipelineService));
5151
}
5252

5353
return developMcpCommand;
@@ -56,14 +56,15 @@ public static Command CreateCommand(
5656
/// <summary>
5757
/// Creates the evaluate subcommand for MCP server tool schema quality evaluation.
5858
/// </summary>
59-
private static Command CreateEvaluateSubcommand(IEvaluationPipelineService pipelineService)
59+
private static Command CreateEvaluateSubcommand(ILogger logger, IEvaluationPipelineService pipelineService)
6060
{
6161
var command = new Command(
6262
"evaluate",
6363
"Evaluate MCP server tool schema quality and generate an HTML report. " +
6464
"Uses a locally installed coding agent (GitHub Copilot or Claude Code) to score semantic checks. " +
6565
"If no agent is detected, the command stops after writing the checklist so you can score it manually with your own LLM, " +
66-
"or pass --eval-engine none to skip agent probing entirely.");
66+
"or pass --eval-engine none to skip agent probing entirely. " +
67+
"Only run this against MCP servers you trust: the server's tool names and descriptions are sent to a locally running coding agent.");
6768

6869
// Use a required option (not a positional argument) for consistency with other
6970
// develop-mcp subcommands and Azure CLI conventions.
@@ -89,7 +90,7 @@ private static Command CreateEvaluateSubcommand(IEvaluationPipelineService pipel
8990

9091
var authTokenOption = new Option<string?>(
9192
"--auth-token",
92-
"Bearer token for MCP server authentication");
93+
"Bearer token for MCP server authentication. Prefer the A365_MCP_AUTH_TOKEN environment variable; a token passed on the command line is visible to process listings (ps / Task Manager) and shell history.");
9394

9495
command.AddOption(serverUrlOption);
9596
command.AddOption(outputDirOption);
@@ -104,6 +105,19 @@ private static Command CreateEvaluateSubcommand(IEvaluationPipelineService pipel
104105
var authToken = context.ParseResult.GetValueForOption(authTokenOption);
105106
var ct = context.GetCancellationToken();
106107

108+
// Secret handling: a token on the command line is visible to other processes
109+
// (ps / Task Manager) and lands in shell history. A non-empty value here can
110+
// only have come from --auth-token, so warn and steer to the env var; when the
111+
// flag is absent, fall back to A365_MCP_AUTH_TOKEN.
112+
if (!string.IsNullOrWhiteSpace(authToken))
113+
{
114+
logger.LogWarning("Passing a token via --auth-token exposes it to process listings (ps / Task Manager) and shell history. Prefer the A365_MCP_AUTH_TOKEN environment variable.");
115+
}
116+
else
117+
{
118+
authToken = Environment.GetEnvironmentVariable("A365_MCP_AUTH_TOKEN");
119+
}
120+
107121
context.ExitCode = await pipelineService.RunAsync(serverUrl, outputDir, evalEngine, authToken, ct);
108122
});
109123

src/Microsoft.Agents.A365.DevTools.Cli/Services/Evaluate/ChecklistEvaluator.cs

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ public async Task<ChecklistEvaluationResult> EvaluateAsync(
8181
if (engine == EvalEngine.None)
8282
{
8383
await WriteChecklistAsync(checklist, checklistPath, cancellationToken);
84-
LogManualEvaluationInstructions(checklistPath, totalUnevaluatedBefore, engineNotFound: false, agentAttempted: false);
84+
LogManualEvaluationInstructions(checklistPath, totalUnevaluatedBefore, engineNotFound: false, agentAttempted: false, requested: engine);
8585
return new ChecklistEvaluationResult { Checklist = checklist, Outcome = EvaluationOutcome.OptedOut };
8686
}
8787

@@ -93,7 +93,7 @@ public async Task<ChecklistEvaluationResult> EvaluateAsync(
9393

9494
if (enginesToTry.Count == 0)
9595
{
96-
LogManualEvaluationInstructions(checklistPath, totalUnevaluatedBefore, engineNotFound: true, agentAttempted: false);
96+
LogManualEvaluationInstructions(checklistPath, totalUnevaluatedBefore, engineNotFound: true, agentAttempted: false, requested: engine);
9797
return new ChecklistEvaluationResult { Checklist = checklist, Outcome = EvaluationOutcome.CouldNotEvaluate };
9898
}
9999

@@ -183,7 +183,7 @@ public async Task<ChecklistEvaluationResult> EvaluateAsync(
183183
// hit tool-permission limits, timed out, or returned without edits. Rather
184184
// than silently producing an inflated report, give the user the same BYOL
185185
// fallback they'd get if no agent was installed at all.
186-
LogManualEvaluationInstructions(checklistPath, remainingUnevaluated, engineNotFound: false, agentAttempted: true);
186+
LogManualEvaluationInstructions(checklistPath, remainingUnevaluated, engineNotFound: false, agentAttempted: true, requested: engine);
187187
}
188188

189189
// Only treat evaluation as completed when nothing is left unscored.
@@ -617,7 +617,7 @@ private static int CountTotalSemanticChecks(EvaluationChecklist checklist)
617617
return count;
618618
}
619619

620-
private void LogManualEvaluationInstructions(string checklistPath, int unscoredCount, bool engineNotFound, bool agentAttempted)
620+
private void LogManualEvaluationInstructions(string checklistPath, int unscoredCount, bool engineNotFound, bool agentAttempted, EvalEngine requested)
621621
{
622622
var fullPath = Path.GetFullPath(checklistPath);
623623
var promptPath = Path.Combine(Path.GetDirectoryName(fullPath) ?? ".", "semantic_eval_prompt.txt");
@@ -635,7 +635,20 @@ private void LogManualEvaluationInstructions(string checklistPath, int unscoredC
635635

636636
if (engineNotFound)
637637
{
638-
_logger.LogWarning(" No coding agent CLI detected (looked for `copilot` and `claude`)");
638+
if (requested == EvalEngine.Auto)
639+
{
640+
// Built from the registry so a newly added engine appears here automatically.
641+
var probed = string.Join(" and ", _launchers.Select(l => $"{l.DisplayName} (`{l.CliCommand}`)"));
642+
_logger.LogWarning(" No coding agent CLI detected (looked for {Probed}). Run with -v to see why each probe failed.", probed);
643+
}
644+
else
645+
{
646+
// The user asked for one specific engine; name only that one, not both.
647+
var launcher = LauncherFor(requested);
648+
var name = launcher?.DisplayName ?? FormatEngineName(requested);
649+
var binary = launcher?.CliCommand ?? requested.ToString();
650+
_logger.LogWarning(" {Name} CLI not found on PATH (looked for `{Binary}`). Run with -v to see why the probe failed.", name, binary);
651+
}
639652
}
640653
else if (agentAttempted)
641654
{

src/Microsoft.Agents.A365.DevTools.Cli/Services/Evaluate/ClaudeCodeLauncher.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ public ClaudeCodeLauncher(CommandExecutor executor, ILogger<ClaudeCodeLauncher>
2929

3030
public override SemanticCheckPrompts.AgentToolset Toolset => new(ReadToolName: "Read", EditToolName: "Edit");
3131

32-
protected override string ProbeCommand => "claude";
32+
public override string CliCommand => "claude";
3333

3434
public override async Task<bool> LaunchAsync(
3535
string prompt,
@@ -63,7 +63,7 @@ private async Task<bool> LaunchViaFileAsync(
6363
await File.WriteAllTextAsync(promptFile, prompt, cancellationToken);
6464

6565
var metaPrompt = $"Read and follow the instructions in the file at: {promptFile}";
66-
var (fileName, fileArguments) = WrapForPlatform("claude", $"-p \"{metaPrompt}\" --model haiku --allowedTools Read,Edit");
66+
var (fileName, fileArguments) = WrapForPlatform("claude", $"-p \"{metaPrompt}\" --model {EvalModelConstants.ClaudeModel} --allowedTools Read,Edit");
6767

6868
var startInfo = new ProcessStartInfo
6969
{
@@ -98,7 +98,7 @@ private async Task<bool> LaunchViaStdinAsync(
9898
var startInfo = new ProcessStartInfo
9999
{
100100
FileName = "claude",
101-
Arguments = "-p - --model haiku --allowedTools Read,Edit",
101+
Arguments = $"-p - --model {EvalModelConstants.ClaudeModel} --allowedTools Read,Edit",
102102
WorkingDirectory = workingDirectory,
103103
RedirectStandardInput = true,
104104
RedirectStandardOutput = true,

src/Microsoft.Agents.A365.DevTools.Cli/Services/Evaluate/CodingAgentLauncherBase.cs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,12 @@ protected CodingAgentLauncherBase(CommandExecutor executor, ILogger logger)
6363
/// <inheritdoc />
6464
public abstract SemanticCheckPrompts.AgentToolset Toolset { get; }
6565

66-
/// <summary>The CLI binary probed with <c>--version</c> to detect availability.</summary>
67-
protected abstract string ProbeCommand { get; }
66+
/// <inheritdoc />
67+
public abstract string CliCommand { get; }
6868

6969
/// <inheritdoc />
7070
public Task<bool> IsAvailableAsync(CancellationToken cancellationToken = default)
71-
=> ProbeCommandAsync(ProbeCommand, "--version", cancellationToken);
71+
=> ProbeCommandAsync(CliCommand, "--version", cancellationToken);
7272

7373
/// <inheritdoc />
7474
public abstract Task<bool> LaunchAsync(string prompt, string workingDirectory, TimeSpan timeout, CancellationToken cancellationToken = default);
@@ -185,6 +185,15 @@ private async Task<bool> ProbeCommandAsync(string command, string arguments, Can
185185
suppressErrorLogging: true,
186186
cancellationToken: cancellationToken);
187187

188+
if (!result.Success)
189+
{
190+
// The probe ran but reported failure (non-zero exit) rather than being
191+
// absent. Surface why at Debug so `-v` distinguishes "not installed" from
192+
// "installed but broken" (auth failure, changed --version flag, etc.).
193+
Logger.LogDebug("{Command} probe exited {ExitCode}; treating as unavailable. {StdErr}",
194+
command, result.ExitCode, result.StandardError?.Trim());
195+
}
196+
188197
return result.Success;
189198
}
190199
catch (Exception ex)
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
namespace Microsoft.Agents.A365.DevTools.Cli.Services.Evaluate;
5+
6+
/// <summary>
7+
/// Single source of truth for the coding-agent model identifiers used during semantic
8+
/// evaluation. Each model has a per-engine environment-variable override so a user can
9+
/// move to a newer model without waiting for a CLI release.
10+
///
11+
/// GitHub Copilot requires an exact model ID (no aliases); Claude Code accepts an
12+
/// alias — so the two values are deliberately separate, not a single shared constant.
13+
/// </summary>
14+
internal static class EvalModelConstants
15+
{
16+
/// <summary>Environment variable that overrides the GitHub Copilot model.</summary>
17+
public const string CopilotModelEnvVar = "A365_EVAL_COPILOT_MODEL";
18+
19+
/// <summary>Environment variable that overrides the Claude Code model.</summary>
20+
public const string ClaudeModelEnvVar = "A365_EVAL_CLAUDE_MODEL";
21+
22+
private const string DefaultCopilotModel = "claude-haiku-4.5";
23+
private const string DefaultClaudeModel = "haiku";
24+
25+
/// <summary>Copilot model ID; overridable via <see cref="CopilotModelEnvVar"/>.</summary>
26+
public static string CopilotModel => FromEnvOrDefault(CopilotModelEnvVar, DefaultCopilotModel);
27+
28+
/// <summary>Claude model ID/alias; overridable via <see cref="ClaudeModelEnvVar"/>.</summary>
29+
public static string ClaudeModel => FromEnvOrDefault(ClaudeModelEnvVar, DefaultClaudeModel);
30+
31+
private static string FromEnvOrDefault(string envVar, string fallback)
32+
{
33+
var value = Environment.GetEnvironmentVariable(envVar);
34+
return string.IsNullOrWhiteSpace(value) ? fallback : value;
35+
}
36+
}

src/Microsoft.Agents.A365.DevTools.Cli/Services/Evaluate/EvaluationPipelineService.cs

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,31 @@ public async Task<int> RunAsync(string serverUrl, string outputDir, string evalE
5353
{
5454
try
5555
{
56-
// Fire-and-forget telemetry marker so non-CLI surfaces that drive evaluations
57-
// also get attributed. Identity is extracted server-side from the bearer token.
58-
// CLI does not pass the evaluated server URL — that is customer content.
59-
// Failures are swallowed inside the service.
60-
await _toolingService.LogEvaluateUsageAsync(cancellationToken);
56+
// Validate the output directory up front so an explicit empty value fails fast
57+
// with a targeted error instead of a deep exception from Directory.CreateDirectory
58+
// late in the run.
59+
if (string.IsNullOrWhiteSpace(outputDir))
60+
{
61+
_logger.LogError("--output-dir cannot be empty or whitespace.");
62+
return 1;
63+
}
64+
65+
// Best-effort usage telemetry, time-bounded so a slow or hung endpoint cannot
66+
// stall the user's run before discovery starts. Identity is extracted server-side
67+
// from the bearer token; the CLI does not pass the evaluated server URL (customer
68+
// content). Failures (swallowed inside the service) and the timeout are ignored.
69+
using (var telemetryCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken))
70+
{
71+
telemetryCts.CancelAfter(TimeSpan.FromSeconds(5));
72+
try
73+
{
74+
await _toolingService.LogEvaluateUsageAsync(telemetryCts.Token);
75+
}
76+
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
77+
{
78+
// Telemetry timed out — non-fatal; continue with the evaluation.
79+
}
80+
}
6181

6282
var engine = ParseEvalEngine(evalEngine);
6383

src/Microsoft.Agents.A365.DevTools.Cli/Services/Evaluate/GitHubCopilotLauncher.cs

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,6 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Services.Evaluate;
1313
/// </summary>
1414
internal sealed class GitHubCopilotLauncher : CodingAgentLauncherBase
1515
{
16-
// Copilot requires an exact model ID (no aliases like "haiku").
17-
// Update this when a newer Haiku version becomes available.
18-
private const string CopilotModel = "claude-haiku-4.5";
19-
2016
public GitHubCopilotLauncher(CommandExecutor executor, ILogger<GitHubCopilotLauncher> logger)
2117
: base(executor, logger)
2218
{
@@ -28,7 +24,7 @@ public GitHubCopilotLauncher(CommandExecutor executor, ILogger<GitHubCopilotLaun
2824

2925
public override SemanticCheckPrompts.AgentToolset Toolset => new(ReadToolName: "view", EditToolName: "edit");
3026

31-
protected override string ProbeCommand => "copilot";
27+
public override string CliCommand => "copilot";
3228

3329
public override async Task<bool> LaunchAsync(
3430
string prompt,
@@ -57,7 +53,7 @@ public override async Task<bool> LaunchAsync(
5753
// sandbox — so view/create/edit stay confined.
5854
var (fileName, fileArguments) = WrapForPlatform(
5955
"copilot",
60-
$"-p \"{metaPrompt}\" --model {CopilotModel} --allow-all-tools " +
56+
$"-p \"{metaPrompt}\" --model {EvalModelConstants.CopilotModel} --allow-all-tools " +
6157
// Restrict visible tools to just read + edit. `create` is specifically
6258
// excluded because Copilot's create cannot overwrite existing files and
6359
// exposing it leads the model down workaround loops (sibling files,

src/Microsoft.Agents.A365.DevTools.Cli/Services/Evaluate/ICodingAgentLauncher.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ internal interface ICodingAgentLauncher
2929
/// </summary>
3030
SemanticCheckPrompts.AgentToolset Toolset { get; }
3131

32+
/// <summary>The CLI binary name on PATH (e.g. "copilot", "claude").</summary>
33+
string CliCommand { get; }
34+
3235
/// <summary>
3336
/// Returns true when the agent's CLI is installed and responds on PATH.
3437
/// </summary>

src/Microsoft.Agents.A365.DevTools.Cli/Services/Evaluate/SchemaDiscoveryService.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Services.Evaluate;
1616
/// Implements the MCP protocol handshake (initialize, notifications/initialized, tools/list)
1717
/// over JSON-RPC 2.0 POST requests.
1818
/// </summary>
19-
internal sealed class SchemaDiscoveryService : ISchemaDiscoveryService
19+
internal sealed class SchemaDiscoveryService : ISchemaDiscoveryService, IDisposable
2020
{
2121
private const string McpProtocolVersion = "2025-03-26";
2222
private const string ClientName = "a365-evaluate";
@@ -33,6 +33,14 @@ public SchemaDiscoveryService(ILogger<SchemaDiscoveryService> logger, HttpMessag
3333
_httpClient = handler != null ? new HttpClient(handler) : HttpClientFactory.CreateAuthenticatedClient();
3434
}
3535

36+
/// <summary>
37+
/// Disposes the owned <see cref="HttpClient"/>. The service is registered as a
38+
/// singleton (Program.cs), so in practice this runs at process shutdown;
39+
/// implementing it keeps the IDisposable contract correct if the registration
40+
/// lifetime ever changes to transient or scoped.
41+
/// </summary>
42+
public void Dispose() => _httpClient.Dispose();
43+
3644
/// <inheritdoc />
3745
public async Task<List<ToolSchema>> DiscoverToolsAsync(string serverUrl, string? authToken = null, CancellationToken cancellationToken = default)
3846
{

0 commit comments

Comments
 (0)