Skip to content

Commit 0ab7834

Browse files
gfraiteurclaude
andcommitted
MCP server: use Haiku with Opus escalation, add file-based TraceLogger
- Risk analysis now uses Haiku first (faster, cheaper), escalates to Opus only if Haiku returns UNCERTAIN - Added RiskLevel.Uncertain to allow models to signal uncertainty - Added TraceLogger singleton that writes to %LOCALAPPDATA%\PostSharp\ McpApprovalServer\logs\mcp-{timestamp}.log - Replaced all Debug.WriteLine with TraceLogger calls - All caught exceptions now logged as Error level before being swallowed - Fixed window activation to move windows to current virtual desktop - Made event firing async to avoid blocking HTTP/UI threads Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 4ac0628 commit 0ab7834

File tree

17 files changed

+348
-124
lines changed

17 files changed

+348
-124
lines changed

src/PostSharp.Engineering.McpApprovalServer/App.xaml.cs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
using PostSharp.Engineering.McpApprovalServer.ViewModels;
88
using PostSharp.Engineering.McpApprovalServer.Views;
99
using System;
10-
using System.Threading.Tasks;
1110
using System.Windows;
1211

1312
namespace PostSharp.Engineering.McpApprovalServer;
@@ -26,7 +25,7 @@ protected override async void OnStartup( StartupEventArgs e )
2625

2726
// Build and configure the host
2827
this._host = Host.CreateDefaultBuilder()
29-
.ConfigureServices( ( context, services ) =>
28+
.ConfigureServices( ( _, services ) =>
3029
{
3130
// Register services
3231
services.AddSingleton<ApprovalRequestQueue>();
@@ -93,4 +92,4 @@ protected override async void OnExit( ExitEventArgs e )
9392

9493
base.OnExit( e );
9594
}
96-
}
95+
}

src/PostSharp.Engineering.McpApprovalServer/Mcp/Models/CommandRecord.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ public sealed class CommandRecord
1919

2020
public required bool Approved { get; init; }
2121

22+
public string? GitBranch { get; init; }
23+
2224
public int? ExitCode { get; init; }
2325

2426
public string? Output { get; init; }

src/PostSharp.Engineering.McpApprovalServer/Mcp/Models/RiskAssessment.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ public enum RiskLevel
1212
Low,
1313
Medium,
1414
High,
15-
Critical
15+
Critical,
16+
Uncertain
1617
}
1718

1819
/// <summary>
@@ -76,6 +77,7 @@ public static RiskAssessment Parse( string output )
7677
"MEDIUM" => RiskLevel.Medium,
7778
"HIGH" => RiskLevel.High,
7879
"CRITICAL" => RiskLevel.Critical,
80+
"UNCERTAIN" => RiskLevel.Uncertain,
7981
_ => RiskLevel.Medium
8082
};
8183
}

src/PostSharp.Engineering.McpApprovalServer/Mcp/Services/CommandExecutor.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details.
22

33
using PostSharp.Engineering.McpApprovalServer.Mcp.Models;
4+
using PostSharp.Engineering.McpApprovalServer.Services;
45
using System;
56
using System.Diagnostics;
67
using System.IO;
@@ -62,6 +63,8 @@ public async Task<CommandResult> ExecuteAsync(
6263
}
6364
catch ( Exception ex )
6465
{
66+
TraceLogger.Logger.Error( "Command execution failed", ex );
67+
6568
return CommandResult.Error( $"Execution error: {ex.Message}" );
6669
}
6770
finally
@@ -74,9 +77,9 @@ public async Task<CommandResult> ExecuteAsync(
7477
File.Delete( tempFile );
7578
}
7679
}
77-
catch
80+
catch ( Exception ex )
7881
{
79-
// Ignore cleanup errors
82+
TraceLogger.Logger.Error( $"Failed to delete temp file {tempFile}: {ex.Message}" );
8083
}
8184
}
8285
}

src/PostSharp.Engineering.McpApprovalServer/Mcp/Services/CommandHistoryService.cs

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ public void Record(
7171
string sessionId,
7272
string command,
7373
string workingDirectory,
74+
string? branch,
7475
string claimedPurpose,
7576
bool approved,
7677
CommandResult result )
@@ -83,6 +84,7 @@ public void Record(
8384
ClaimedPurpose = claimedPurpose,
8485
Approved = approved,
8586
ExitCode = result.ExitCode,
87+
GitBranch = branch,
8688
Output = TruncateOutput( result.Output )
8789
};
8890

@@ -114,9 +116,9 @@ public bool WasPreviouslyApproved( string sessionId, string command, string work
114116
lock ( this._lock )
115117
{
116118
return this._history.Any( r =>
117-
r.Approved &&
118-
r.Command.Equals( command, StringComparison.Ordinal ) &&
119-
r.WorkingDirectory.Equals( workingDirectory, StringComparison.OrdinalIgnoreCase ) );
119+
r.Approved &&
120+
r.Command.Equals( command, StringComparison.Ordinal ) &&
121+
r.WorkingDirectory.Equals( workingDirectory, StringComparison.OrdinalIgnoreCase ) );
120122
}
121123
}
122124

@@ -144,7 +146,7 @@ private void LoadHistory()
144146
catch ( Exception ex )
145147
{
146148
// Log but don't fail - history is not critical
147-
System.Diagnostics.Debug.WriteLine( $"Failed to load command history: {ex.Message}" );
149+
TraceLogger.Logger.Error( "Failed to load command history", ex );
148150
}
149151
}
150152

@@ -161,15 +163,11 @@ private void SaveHistory()
161163
catch ( Exception ex )
162164
{
163165
// Log but don't fail - history is not critical
164-
System.Diagnostics.Debug.WriteLine( $"Failed to save command history: {ex.Message}" );
166+
TraceLogger.Logger.Error( "Failed to save command history", ex );
165167
}
166168
}
167169

168-
private static JsonSerializerOptions GetJsonOptions() => new()
169-
{
170-
WriteIndented = true,
171-
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
172-
};
170+
private static JsonSerializerOptions GetJsonOptions() => new() { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
173171

174172
private static string? TruncateOutput( string? output )
175173
{
@@ -195,8 +193,7 @@ private static void AppendToAuditTrail( CommandRecord record )
195193
var dateStr = DateTime.UtcNow.ToString( "yyyy-MM-dd", CultureInfo.InvariantCulture );
196194
var auditFilePath = Path.Combine( _auditDirectory, $"audit-{dateStr}.log" );
197195

198-
// Get git branch for the working directory
199-
var gitBranch = GitHelper.GetBranch( record.WorkingDirectory );
196+
var gitBranch = record.GitBranch;
200197

201198
// Format: timestamp | approved/rejected | command | purpose | working_dir | branch | exit_code
202199
var status = record.Approved ? "APPROVED" : "REJECTED";
@@ -215,7 +212,7 @@ private static void AppendToAuditTrail( CommandRecord record )
215212
catch ( Exception ex )
216213
{
217214
// Log but don't fail - audit is not critical for operation
218-
System.Diagnostics.Debug.WriteLine( $"Failed to append to audit trail: {ex.Message}" );
215+
TraceLogger.Logger.Error( "Failed to append to audit trail", ex );
219216
}
220217
}
221-
}
218+
}

src/PostSharp.Engineering.McpApprovalServer/Mcp/Services/RegexRuleEngine.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details.
22

33
using PostSharp.Engineering.McpApprovalServer.Mcp.Models;
4+
using PostSharp.Engineering.McpApprovalServer.Services;
5+
using System;
46
using System.Collections.Generic;
57
using System.Diagnostics;
68
using System.IO;
@@ -122,11 +124,12 @@ private static bool TryRunGitCommand( string workingDirectory, string arguments,
122124

123125
return process.ExitCode == 0;
124126
}
125-
catch
127+
catch ( Exception ex )
126128
{
129+
TraceLogger.Logger.Error( $"Git command failed: {ex.Message}" );
127130
output = string.Empty;
128131

129132
return false;
130133
}
131134
}
132-
}
135+
}

src/PostSharp.Engineering.McpApprovalServer/Mcp/Services/RiskAnalyzer.cs

Lines changed: 56 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using System.Collections.Generic;
77
using System.Diagnostics;
88
using System.Globalization;
9+
using static PostSharp.Engineering.McpApprovalServer.Services.TraceLogger;
910
using System.Linq;
1011
using System.Text;
1112
using System.Text.RegularExpressions;
@@ -171,13 +172,46 @@ public async Task<RiskAssessment> AnalyzeAsync(
171172
{
172173
var prompt = await BuildAnalysisPromptAsync( command, claimedPurpose, workingDirectory, history, cancellationToken );
173174

175+
// First try with Haiku (fast and cheap)
176+
Logger.Trace( "RiskAnalyzer", "Starting risk analysis with Haiku..." );
177+
var assessment = await InvokeClaudeAsync( prompt, "haiku", cancellationToken );
178+
179+
// If Haiku is uncertain, escalate to Opus
180+
if ( assessment.Level == RiskLevel.Uncertain )
181+
{
182+
Logger.Trace( "RiskAnalyzer", "Haiku returned UNCERTAIN, escalating to Opus..." );
183+
assessment = await InvokeClaudeAsync( prompt, "opus", cancellationToken );
184+
185+
// If Opus is still uncertain, treat as medium risk requiring human review
186+
if ( assessment.Level == RiskLevel.Uncertain )
187+
{
188+
Logger.Trace( "RiskAnalyzer", "Opus also returned UNCERTAIN, defaulting to medium risk" );
189+
190+
return new RiskAssessment
191+
{
192+
Level = RiskLevel.Medium,
193+
Recommendation = Recommendation.Approve,
194+
Reason = assessment.Reason + " (escalated analysis was also uncertain)",
195+
Description = assessment.Description
196+
};
197+
}
198+
}
199+
200+
return assessment;
201+
}
202+
203+
private static async Task<RiskAssessment> InvokeClaudeAsync(
204+
string prompt,
205+
string model,
206+
CancellationToken cancellationToken )
207+
{
174208
try
175209
{
176210
using var timeoutCts = new CancellationTokenSource( TimeSpan.FromSeconds( 120 ) );
177211
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( cancellationToken, timeoutCts.Token );
178212

179-
Debug.WriteLine( "Starting Claude CLI for risk analysis..." );
180-
Debug.WriteLine( $"Prompt length: {prompt.Length} chars" );
213+
Logger.Trace( "RiskAnalyzer", $"Starting Claude CLI with model {model}..." );
214+
Logger.Trace( "RiskAnalyzer", $"Prompt length: {prompt.Length} chars" );
181215

182216
// On Windows, npm installs create .cmd wrapper scripts, not .exe files.
183217
// Process.Start with UseShellExecute=false doesn't resolve .cmd files via PATH.
@@ -186,7 +220,7 @@ public async Task<RiskAssessment> AnalyzeAsync(
186220
var startInfo = new ProcessStartInfo
187221
{
188222
FileName = "cmd",
189-
Arguments = "/c claude",
223+
Arguments = $"/c claude --model {model}",
190224
RedirectStandardInput = true,
191225
RedirectStandardOutput = true,
192226
RedirectStandardError = true,
@@ -198,12 +232,12 @@ public async Task<RiskAssessment> AnalyzeAsync(
198232

199233
if ( process == null )
200234
{
201-
Debug.WriteLine( "Failed to start Claude CLI process" );
235+
Logger.Trace( "RiskAnalyzer", "Failed to start Claude CLI process" );
202236

203237
return RiskAssessment.Default( "Failed to start Claude CLI for analysis" );
204238
}
205239

206-
Debug.WriteLine( $"Claude CLI started (PID: {process.Id})" );
240+
Logger.Trace( "RiskAnalyzer", $"Claude CLI started (PID: {process.Id}, model: {model})" );
207241

208242
// Write the prompt to stdin and close the stream
209243
await process.StandardInput.WriteAsync( prompt.AsMemory(), linkedCts.Token );
@@ -213,21 +247,21 @@ public async Task<RiskAssessment> AnalyzeAsync(
213247
var stderr = await process.StandardError.ReadToEndAsync( linkedCts.Token );
214248
await process.WaitForExitAsync( linkedCts.Token );
215249

216-
Debug.WriteLine( $"Claude CLI exited with code: {process.ExitCode}" );
250+
Logger.Trace( "RiskAnalyzer", $"Claude CLI exited with code: {process.ExitCode} (model: {model})" );
217251

218252
if ( !string.IsNullOrWhiteSpace( stderr ) )
219253
{
220-
Debug.WriteLine( $"Claude CLI stderr: {stderr}" );
254+
Logger.Trace( "RiskAnalyzer", $"Claude CLI stderr: {stderr}" );
221255
}
222256

223257
if ( !string.IsNullOrWhiteSpace( output ) )
224258
{
225-
Debug.WriteLine( $"Claude CLI output: {( output.Length > 500 ? output[..500] + "..." : output )}" );
259+
Logger.Trace( "RiskAnalyzer", $"Claude CLI output: {(output.Length > 500 ? output[..500] + "..." : output)}" );
226260
}
227261

228262
if ( process.ExitCode != 0 )
229263
{
230-
Debug.WriteLine( $"Claude CLI failed with exit code {process.ExitCode}" );
264+
Logger.Trace( "RiskAnalyzer", $"Claude CLI failed with exit code {process.ExitCode}" );
231265

232266
return RiskAssessment.Default( "Claude CLI analysis failed" );
233267
}
@@ -236,13 +270,13 @@ public async Task<RiskAssessment> AnalyzeAsync(
236270
}
237271
catch ( OperationCanceledException )
238272
{
239-
Debug.WriteLine( "Claude CLI analysis timed out" );
273+
Logger.Trace( "RiskAnalyzer", $"Claude CLI analysis timed out (model: {model})" );
240274

241275
return RiskAssessment.Default( "Analysis timed out - human review required" );
242276
}
243277
catch ( Exception ex )
244278
{
245-
Debug.WriteLine( $"Claude CLI error: {ex.Message}" );
279+
Logger.Error( $"Claude CLI error (model: {model}): {ex.Message}" );
246280

247281
return RiskAssessment.Default( $"Analysis error: {ex.Message}" );
248282
}
@@ -339,10 +373,16 @@ private static async Task<string> BuildAnalysisPromptAsync(
339373
sb.AppendLine( "Respond with EXACTLY these four lines (no additional text):" );
340374
sb.AppendLine( "```" );
341375
sb.AppendLine( "DESCRIPTION: <one concise sentence describing what the command does>" );
342-
sb.AppendLine( "RISK: LOW|MEDIUM|HIGH|CRITICAL" );
376+
sb.AppendLine( "RISK: LOW|MEDIUM|HIGH|CRITICAL|UNCERTAIN" );
343377
sb.AppendLine( "RECOMMEND: APPROVE|REJECT" );
344378
sb.AppendLine( "REASON: <one concise sentence explaining your risk assessment>" );
345379
sb.AppendLine( "```" );
380+
sb.AppendLine();
381+
sb.AppendLine( "Use RISK: UNCERTAIN only if you genuinely cannot determine the risk level due to:" );
382+
sb.AppendLine( "- Complex multi-step attack patterns you're unsure about" );
383+
sb.AppendLine( "- Obfuscated or encoded content you cannot fully analyze" );
384+
sb.AppendLine( "- Unusual commands outside your training knowledge" );
385+
sb.AppendLine( "Do NOT use UNCERTAIN for normal ambiguous cases - make your best judgment." );
346386

347387
return sb.ToString();
348388
}
@@ -398,10 +438,11 @@ private static bool IsGitPushCommand( string command )
398438

399439
return sb.ToString();
400440
}
401-
catch
441+
catch ( Exception ex )
402442
{
443+
Logger.Error( $"Failed to get commit diff: {ex.Message}" );
444+
403445
return null; // If we can't get diff, proceed without it
404446
}
405447
}
406-
407-
}
448+
}

src/PostSharp.Engineering.McpApprovalServer/Mcp/Tools/ExecuteCommandTool.cs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using ModelContextProtocol.Server;
44
using PostSharp.Engineering.McpApprovalServer.Mcp.Models;
55
using PostSharp.Engineering.McpApprovalServer.Mcp.Services;
6+
using PostSharp.Engineering.McpApprovalServer.Services;
67
using System;
78
using System.ComponentModel;
89
using System.Threading;
@@ -53,14 +54,16 @@ public async Task<CommandResult> ExecuteCommand(
5354
{
5455
// Use a constant session ID for single session model
5556
const string sessionId = "default";
57+
var branch = await GitHelper.GetBranchAsync( workingDirectory, cancellationToken );
5658

5759
// Log incoming request
5860
this._logger?.LogSection( "Incoming Command Request" );
5961
this._logger?.LogInfo( $"Time: {DateTime.Now:yyyy-MM-dd HH:mm:ss}" );
6062
this._logger?.LogInfo( $"Command: {command}" );
6163
this._logger?.LogInfo( $"Working Directory: {workingDirectory}" );
6264
this._logger?.LogInfo( $"Purpose: {claimedPurpose}" );
63-
65+
this._logger?.LogInfo( $"Git Branch: {branch}" );
66+
6467
try
6568
{
6669
// 1. Check if this exact command was previously approved
@@ -74,7 +77,7 @@ public async Task<CommandResult> ExecuteCommand(
7477
this._logger?.LogSection( "Request Completed" );
7578

7679
// Record in history
77-
this._history.Record( sessionId, command, workingDirectory, claimedPurpose, approved: true, autoApprovedResult );
80+
this._history.Record( sessionId, command, workingDirectory, branch, claimedPurpose, approved: true, autoApprovedResult );
7881

7982
return autoApprovedResult;
8083
}
@@ -134,7 +137,7 @@ public async Task<CommandResult> ExecuteCommand(
134137
}
135138

136139
// 7. Record in history
137-
this._history.Record( sessionId, command, workingDirectory, claimedPurpose, approved, result );
140+
this._history.Record( sessionId, command, workingDirectory, branch, claimedPurpose, approved, result );
138141

139142
return result;
140143
}

src/PostSharp.Engineering.McpApprovalServer/Services/ApprovalRequest.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ public sealed class ApprovalRequest
1919

2020
public required string WorkingDirectory { get; init; }
2121

22-
public required string GitBranch { get; init; }
22+
public required string? GitBranch { get; init; }
2323

2424
public required RiskAssessment CombinedAssessment { get; init; }
2525

0 commit comments

Comments
 (0)