Skip to content

Commit a05597d

Browse files
authored
Merge pull request #426 from jongalloway/copilot/include-compiler-error-messages
Include compiler error details (file, line, column) in Build action structured response
2 parents 4bdb696 + 12d1184 commit a05597d

8 files changed

Lines changed: 535 additions & 5 deletions

File tree

.config/dotnet-tools.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"version": 1,
33
"isRoot": true,
44
"tools": {}
5-
}
5+
}

DotNetMcp.Tests/Errors/ErrorResultFactoryTests.cs

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1347,5 +1347,170 @@ public void CreateResult_WithExitCode106_SerializesToValidJson()
13471347
}
13481348

13491349
#endregion
1350+
1351+
#region ParseBuildOutput Tests
1352+
1353+
[Fact]
1354+
public void ParseBuildOutput_WithCompilerError_ReturnsFailureWithDiagnostic()
1355+
{
1356+
// Arrange - simulate what DotNetCommandExecutor.ExecuteCommandAsync returns for a failed build
1357+
var rawOutput = """
1358+
Command: dotnet build "MyProject.csproj"
1359+
Build FAILED.
1360+
1361+
Build FAILED.
1362+
1363+
Error(s)
1364+
Program.cs(9,51): error CS0246: The type or namespace name 'IRenderable' could not be found (are you missing a using directive or an assembly reference?) [MyProject.csproj]
1365+
0 Warning(s)
1366+
1 Error(s)
1367+
1368+
Exit Code: 1
1369+
""";
1370+
1371+
// Act
1372+
var result = ErrorResultFactory.ParseBuildOutput(rawOutput, project: "MyProject.csproj", configuration: "Debug");
1373+
1374+
// Assert
1375+
Assert.False(result.Success);
1376+
Assert.Equal("MyProject.csproj", result.Project);
1377+
Assert.Equal("Debug", result.Configuration);
1378+
Assert.Equal(1, result.ErrorCount);
1379+
Assert.Equal(0, result.WarningCount);
1380+
Assert.Contains("FAILED", result.Summary, StringComparison.OrdinalIgnoreCase);
1381+
1382+
Assert.NotNull(result.Errors);
1383+
var diag = Assert.Single(result.Errors);
1384+
Assert.Equal("CS0246", diag.Code);
1385+
Assert.Contains("IRenderable", diag.Message);
1386+
Assert.Equal("error", diag.Severity);
1387+
Assert.Equal(9, diag.Line);
1388+
Assert.Equal(51, diag.Column);
1389+
Assert.False(string.IsNullOrWhiteSpace(diag.File));
1390+
Assert.Contains("Program.cs", diag.File);
1391+
1392+
Assert.Null(result.Warnings);
1393+
}
1394+
1395+
[Fact]
1396+
public void ParseBuildOutput_WithWarning_ReturnsSuccessWithWarningDiagnostic()
1397+
{
1398+
// Arrange
1399+
var rawOutput = """
1400+
Command: dotnet build "MyProject.csproj"
1401+
Program.cs(3,1): warning CS0219: The variable 'x' is assigned but its value is never used [MyProject.csproj]
1402+
Build succeeded.
1403+
1 Warning(s)
1404+
0 Error(s)
1405+
1406+
Exit Code: 0
1407+
""";
1408+
1409+
// Act
1410+
var result = ErrorResultFactory.ParseBuildOutput(rawOutput, project: "MyProject.csproj");
1411+
1412+
// Assert
1413+
Assert.True(result.Success);
1414+
Assert.Equal(0, result.ErrorCount);
1415+
Assert.Equal(1, result.WarningCount);
1416+
Assert.Contains("succeeded", result.Summary, StringComparison.OrdinalIgnoreCase);
1417+
1418+
Assert.Null(result.Errors);
1419+
Assert.NotNull(result.Warnings);
1420+
var diag = Assert.Single(result.Warnings);
1421+
Assert.Equal("CS0219", diag.Code);
1422+
Assert.Equal("warning", diag.Severity);
1423+
Assert.Equal(3, diag.Line);
1424+
Assert.Equal(1, diag.Column);
1425+
}
1426+
1427+
[Fact]
1428+
public void ParseBuildOutput_WithSuccessNoWarnings_ReturnsCleanSuccess()
1429+
{
1430+
// Arrange
1431+
var rawOutput = """
1432+
Command: dotnet build "MyProject.csproj"
1433+
Build succeeded.
1434+
0 Warning(s)
1435+
0 Error(s)
1436+
1437+
Exit Code: 0
1438+
""";
1439+
1440+
// Act
1441+
var result = ErrorResultFactory.ParseBuildOutput(rawOutput, project: "MyProject.csproj");
1442+
1443+
// Assert
1444+
Assert.True(result.Success);
1445+
Assert.Equal(0, result.ErrorCount);
1446+
Assert.Equal(0, result.WarningCount);
1447+
Assert.Equal("Build succeeded", result.Summary);
1448+
Assert.Null(result.Errors);
1449+
Assert.Null(result.Warnings);
1450+
}
1451+
1452+
[Fact]
1453+
public void ParseBuildOutput_WithNullInput_ReturnsSafeSuccess()
1454+
{
1455+
var result = ErrorResultFactory.ParseBuildOutput(null!);
1456+
Assert.True(result.Success);
1457+
}
1458+
1459+
[Fact]
1460+
public void ParseBuildOutput_WithEmptyInput_ReturnsSafeSuccess()
1461+
{
1462+
var result = ErrorResultFactory.ParseBuildOutput(string.Empty);
1463+
Assert.True(result.Success);
1464+
}
1465+
1466+
[Fact]
1467+
public void ParseBuildOutput_WithAbsoluteProjectPath_SanitizesToFilename()
1468+
{
1469+
// Absolute project paths should be reduced to just the filename to avoid leaking machine paths.
1470+
// Path.GetFullPath ensures this is a rooted/absolute path on any platform.
1471+
var absolutePath = Path.GetFullPath(Path.Join("my-project", "MyApp.csproj"));
1472+
var rawOutput = "Command: dotnet build\nBuild succeeded.\n 0 Warning(s)\n 0 Error(s)\nExit Code: 0";
1473+
1474+
var result = ErrorResultFactory.ParseBuildOutput(rawOutput, project: absolutePath);
1475+
1476+
Assert.True(result.Success);
1477+
Assert.Equal("MyApp.csproj", result.Project);
1478+
}
1479+
1480+
[Fact]
1481+
public void ParseBuildOutput_WithRelativeProjectPath_PreservesAsIs()
1482+
{
1483+
var result = ErrorResultFactory.ParseBuildOutput(
1484+
"Command: dotnet build\nBuild succeeded.\nExit Code: 0",
1485+
project: "src/MyApp.csproj");
1486+
1487+
Assert.Equal("src/MyApp.csproj", result.Project);
1488+
}
1489+
1490+
#endregion
1491+
1492+
#region ErrorResult File/Line/Column Tests
1493+
1494+
[Fact]
1495+
public void CreateResult_WithCompilerError_PopulatesFileLineColumn()
1496+
{
1497+
// Arrange
1498+
var output = "Program.cs(9,51): error CS0246: The type or namespace name 'IRenderable' could not be found";
1499+
var error = "";
1500+
var exitCode = 1;
1501+
1502+
// Act
1503+
var result = ErrorResultFactory.CreateResult(output, error, exitCode);
1504+
1505+
// Assert
1506+
var errorResponse = Assert.IsType<ErrorResponse>(result);
1507+
var parsedError = Assert.Single(errorResponse.Errors);
1508+
Assert.Equal("CS0246", parsedError.Code);
1509+
Assert.Contains("Program.cs", parsedError.File);
1510+
Assert.Equal(9, parsedError.Line);
1511+
Assert.Equal(51, parsedError.Column);
1512+
}
1513+
1514+
#endregion
13501515
}
13511516

DotNetMcp.Tests/Scenarios/BuildErrorScenarioTests.cs

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Text.Json;
12
using DotNetMcp;
23
using Xunit;
34

@@ -26,14 +27,14 @@ public async Task Scenario_DotnetProject_Build_WithCompileError_ReturnsMachineRe
2627
var projectDir = Path.GetDirectoryName(projectPath);
2728
Assert.False(string.IsNullOrWhiteSpace(projectDir));
2829

29-
// Introduce a compile error.
30+
// Introduce a compile error: reference an unresolved type so we get a CS0246 diagnostic.
3031
var programPath = Path.Join(projectDir!, "Program.cs");
3132
Assert.True(File.Exists(programPath), "Expected Program.cs to exist");
32-
await File.AppendAllTextAsync(programPath, "\nthis_will_not_compile\n", cancellationToken);
33+
await File.WriteAllTextAsync(programPath, "IAmABogusType bogus = new IAmABogusType();\n", cancellationToken);
3334

3435
await using var client = await McpScenarioClient.CreateAsync(cancellationToken);
3536

36-
var text = await client.CallToolTextAsync(
37+
var result = await client.CallToolAsync(
3738
toolName: "dotnet_project",
3839
args: new Dictionary<string, object?>
3940
{
@@ -43,7 +44,49 @@ public async Task Scenario_DotnetProject_Build_WithCompileError_ReturnsMachineRe
4344
},
4445
cancellationToken);
4546

46-
// Contract sanity: output should mention a build/compile failure.
47+
// Text content sanity: output should mention a build/compile failure.
48+
var text = result.GetText();
4749
Assert.Contains("error", text, StringComparison.OrdinalIgnoreCase);
50+
51+
// Structured content: verify BuildResult is present and includes compiler error details.
52+
Assert.True(result.StructuredContent.HasValue, "Expected structured content in Build response");
53+
var structuredJson = result.StructuredContent!.Value.GetRawText();
54+
Assert.False(string.IsNullOrWhiteSpace(structuredJson));
55+
56+
using var doc = JsonDocument.Parse(structuredJson);
57+
var root = doc.RootElement;
58+
59+
// success should be false
60+
Assert.True(root.TryGetProperty("success", out var successProp));
61+
Assert.False(successProp.GetBoolean());
62+
63+
// errorCount should be > 0
64+
Assert.True(root.TryGetProperty("errorCount", out var errorCountProp));
65+
Assert.True(errorCountProp.GetInt32() > 0, "Expected at least one error in errorCount");
66+
67+
// errors array should be present and non-empty
68+
Assert.True(root.TryGetProperty("errors", out var errorsProp), "Expected 'errors' array in BuildResult");
69+
Assert.Equal(JsonValueKind.Array, errorsProp.ValueKind);
70+
Assert.True(errorsProp.GetArrayLength() > 0, "Expected at least one entry in 'errors' array");
71+
72+
// Each diagnostic should have file, line, column, code, message
73+
foreach (var diagnostic in errorsProp.EnumerateArray())
74+
{
75+
Assert.True(diagnostic.TryGetProperty("code", out var codeProp), "Diagnostic missing 'code'");
76+
Assert.False(string.IsNullOrWhiteSpace(codeProp.GetString()), "Diagnostic 'code' should not be empty");
77+
78+
Assert.True(diagnostic.TryGetProperty("message", out var msgProp), "Diagnostic missing 'message'");
79+
Assert.False(string.IsNullOrWhiteSpace(msgProp.GetString()), "Diagnostic 'message' should not be empty");
80+
81+
// file/line/column should be present for Roslyn compiler errors
82+
Assert.True(diagnostic.TryGetProperty("file", out var fileProp), "Diagnostic missing 'file'");
83+
Assert.False(string.IsNullOrWhiteSpace(fileProp.GetString()), "Diagnostic 'file' should not be empty");
84+
85+
Assert.True(diagnostic.TryGetProperty("line", out var lineProp), "Diagnostic missing 'line'");
86+
Assert.True(lineProp.GetInt32() > 0, "Diagnostic 'line' should be > 0");
87+
88+
Assert.True(diagnostic.TryGetProperty("column", out var colProp), "Diagnostic missing 'column'");
89+
Assert.True(colProp.GetInt32() > 0, "Diagnostic 'column' should be > 0");
90+
}
4891
}
4992
}

DotNetMcp.Tests/Scenarios/McpScenarioClient.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,23 @@ public async Task<string> CallToolTextAsync(string toolName, Dictionary<string,
8585
return text ?? string.Empty;
8686
}
8787

88+
/// <summary>
89+
/// Calls an MCP tool and returns the full <see cref="CallToolResult"/>, including any structured content.
90+
/// </summary>
91+
/// <param name="toolName">The name of the tool to call.</param>
92+
/// <param name="args">The arguments to pass to the tool.</param>
93+
/// <param name="cancellationToken">A cancellation token to cancel the operation.</param>
94+
/// <returns>The full <see cref="CallToolResult"/> from the tool response.</returns>
95+
public async Task<CallToolResult> CallToolAsync(string toolName, Dictionary<string, object?> args, CancellationToken cancellationToken)
96+
{
97+
if (_client is null)
98+
{
99+
throw new InvalidOperationException("MCP client not initialized.");
100+
}
101+
102+
return await _client.CallToolAsync(toolName, args, cancellationToken: cancellationToken);
103+
}
104+
88105
/// <summary>
89106
/// Disposes the MCP client and releases all associated resources.
90107
/// </summary>

DotNetMcp.Tests/Tools/ConsolidatedProjectToolTests.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1807,6 +1807,32 @@ public async Task DotnetProject_Build_WithAllNewFlags_WiresAll()
18071807
MachineReadableCommandAssertions.AssertExecutedDotnetCommand(result, "dotnet build \"MyProject.csproj\" -c Release -f net8.0 --no-restore -v minimal -o \"out\"");
18081808
}
18091809

1810+
[Fact]
1811+
public async Task DotnetProject_Build_ReturnsStructuredContent()
1812+
{
1813+
// Build action should always return structured content (BuildResult) regardless of success/failure.
1814+
var callResult = await _tools.DotnetProject(
1815+
action: DotnetProjectAction.Build,
1816+
project: "MyProject.csproj",
1817+
configuration: "Release");
1818+
1819+
// Text content should still be present
1820+
var text = callResult.GetText();
1821+
Assert.NotNull(text);
1822+
1823+
// Structured content must be populated
1824+
Assert.True(callResult.StructuredContent.HasValue, "Build action should always return structured content");
1825+
var structuredJson = callResult.StructuredContent!.Value.GetRawText();
1826+
Assert.False(string.IsNullOrWhiteSpace(structuredJson));
1827+
1828+
// The structured content should be a valid BuildResult with success/errorCount/summary
1829+
using var doc = System.Text.Json.JsonDocument.Parse(structuredJson);
1830+
var root = doc.RootElement;
1831+
Assert.True(root.TryGetProperty("success", out _), "BuildResult should have 'success' field");
1832+
Assert.True(root.TryGetProperty("errorCount", out _), "BuildResult should have 'errorCount' field");
1833+
Assert.True(root.TryGetProperty("summary", out _), "BuildResult should have 'summary' field");
1834+
}
1835+
18101836
[Fact]
18111837
public async Task DotnetProject_Restore_WithVerbosity_WiresFlag()
18121838
{

0 commit comments

Comments
 (0)