Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .config/dotnet-tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
"version": 1,
"isRoot": true,
"tools": {}
}
}
165 changes: 165 additions & 0 deletions DotNetMcp.Tests/Errors/ErrorResultFactoryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1347,5 +1347,170 @@ public void CreateResult_WithExitCode106_SerializesToValidJson()
}

#endregion

#region ParseBuildOutput Tests

[Fact]
public void ParseBuildOutput_WithCompilerError_ReturnsFailureWithDiagnostic()
{
// Arrange - simulate what DotNetCommandExecutor.ExecuteCommandAsync returns for a failed build
var rawOutput = """
Command: dotnet build "MyProject.csproj"
Build FAILED.

Build FAILED.

Error(s)
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]
0 Warning(s)
1 Error(s)

Exit Code: 1
""";

// Act
var result = ErrorResultFactory.ParseBuildOutput(rawOutput, project: "MyProject.csproj", configuration: "Debug");

// Assert
Assert.False(result.Success);
Assert.Equal("MyProject.csproj", result.Project);
Assert.Equal("Debug", result.Configuration);
Assert.Equal(1, result.ErrorCount);
Assert.Equal(0, result.WarningCount);
Assert.Contains("FAILED", result.Summary, StringComparison.OrdinalIgnoreCase);

Assert.NotNull(result.Errors);
var diag = Assert.Single(result.Errors);
Assert.Equal("CS0246", diag.Code);
Assert.Contains("IRenderable", diag.Message);
Assert.Equal("error", diag.Severity);
Assert.Equal(9, diag.Line);
Assert.Equal(51, diag.Column);
Assert.False(string.IsNullOrWhiteSpace(diag.File));
Assert.Contains("Program.cs", diag.File);

Assert.Null(result.Warnings);
}

[Fact]
public void ParseBuildOutput_WithWarning_ReturnsSuccessWithWarningDiagnostic()
{
// Arrange
var rawOutput = """
Command: dotnet build "MyProject.csproj"
Program.cs(3,1): warning CS0219: The variable 'x' is assigned but its value is never used [MyProject.csproj]
Build succeeded.
1 Warning(s)
0 Error(s)

Exit Code: 0
""";

// Act
var result = ErrorResultFactory.ParseBuildOutput(rawOutput, project: "MyProject.csproj");

// Assert
Assert.True(result.Success);
Assert.Equal(0, result.ErrorCount);
Assert.Equal(1, result.WarningCount);
Assert.Contains("succeeded", result.Summary, StringComparison.OrdinalIgnoreCase);

Assert.Null(result.Errors);
Assert.NotNull(result.Warnings);
var diag = Assert.Single(result.Warnings);
Assert.Equal("CS0219", diag.Code);
Assert.Equal("warning", diag.Severity);
Assert.Equal(3, diag.Line);
Assert.Equal(1, diag.Column);
}

[Fact]
public void ParseBuildOutput_WithSuccessNoWarnings_ReturnsCleanSuccess()
{
// Arrange
var rawOutput = """
Command: dotnet build "MyProject.csproj"
Build succeeded.
0 Warning(s)
0 Error(s)

Exit Code: 0
""";

// Act
var result = ErrorResultFactory.ParseBuildOutput(rawOutput, project: "MyProject.csproj");

// Assert
Assert.True(result.Success);
Assert.Equal(0, result.ErrorCount);
Assert.Equal(0, result.WarningCount);
Assert.Equal("Build succeeded", result.Summary);
Assert.Null(result.Errors);
Assert.Null(result.Warnings);
}

[Fact]
public void ParseBuildOutput_WithNullInput_ReturnsSafeSuccess()
{
var result = ErrorResultFactory.ParseBuildOutput(null!);
Assert.True(result.Success);
}

[Fact]
public void ParseBuildOutput_WithEmptyInput_ReturnsSafeSuccess()
{
var result = ErrorResultFactory.ParseBuildOutput(string.Empty);
Assert.True(result.Success);
}

[Fact]
public void ParseBuildOutput_WithAbsoluteProjectPath_SanitizesToFilename()
{
// Absolute project paths should be reduced to just the filename to avoid leaking machine paths.
// Path.GetFullPath ensures this is a rooted/absolute path on any platform.
var absolutePath = Path.GetFullPath(Path.Join("my-project", "MyApp.csproj"));
var rawOutput = "Command: dotnet build\nBuild succeeded.\n 0 Warning(s)\n 0 Error(s)\nExit Code: 0";

var result = ErrorResultFactory.ParseBuildOutput(rawOutput, project: absolutePath);

Assert.True(result.Success);
Assert.Equal("MyApp.csproj", result.Project);
}

[Fact]
public void ParseBuildOutput_WithRelativeProjectPath_PreservesAsIs()
{
var result = ErrorResultFactory.ParseBuildOutput(
"Command: dotnet build\nBuild succeeded.\nExit Code: 0",
project: "src/MyApp.csproj");

Assert.Equal("src/MyApp.csproj", result.Project);
}

#endregion

#region ErrorResult File/Line/Column Tests

[Fact]
public void CreateResult_WithCompilerError_PopulatesFileLineColumn()
{
// Arrange
var output = "Program.cs(9,51): error CS0246: The type or namespace name 'IRenderable' could not be found";
var error = "";
var exitCode = 1;

// Act
var result = ErrorResultFactory.CreateResult(output, error, exitCode);

// Assert
var errorResponse = Assert.IsType<ErrorResponse>(result);
var parsedError = Assert.Single(errorResponse.Errors);
Assert.Equal("CS0246", parsedError.Code);
Assert.Contains("Program.cs", parsedError.File);
Assert.Equal(9, parsedError.Line);
Assert.Equal(51, parsedError.Column);
}

#endregion
}

51 changes: 47 additions & 4 deletions DotNetMcp.Tests/Scenarios/BuildErrorScenarioTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Text.Json;
using DotNetMcp;
using Xunit;

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

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

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

var text = await client.CallToolTextAsync(
var result = await client.CallToolAsync(
toolName: "dotnet_project",
args: new Dictionary<string, object?>
{
Expand All @@ -43,7 +44,49 @@ public async Task Scenario_DotnetProject_Build_WithCompileError_ReturnsMachineRe
},
cancellationToken);

// Contract sanity: output should mention a build/compile failure.
// Text content sanity: output should mention a build/compile failure.
var text = result.GetText();
Assert.Contains("error", text, StringComparison.OrdinalIgnoreCase);

// Structured content: verify BuildResult is present and includes compiler error details.
Assert.True(result.StructuredContent.HasValue, "Expected structured content in Build response");
var structuredJson = result.StructuredContent!.Value.GetRawText();
Assert.False(string.IsNullOrWhiteSpace(structuredJson));

using var doc = JsonDocument.Parse(structuredJson);
var root = doc.RootElement;

// success should be false
Assert.True(root.TryGetProperty("success", out var successProp));
Assert.False(successProp.GetBoolean());

// errorCount should be > 0
Assert.True(root.TryGetProperty("errorCount", out var errorCountProp));
Assert.True(errorCountProp.GetInt32() > 0, "Expected at least one error in errorCount");

// errors array should be present and non-empty
Assert.True(root.TryGetProperty("errors", out var errorsProp), "Expected 'errors' array in BuildResult");
Assert.Equal(JsonValueKind.Array, errorsProp.ValueKind);
Assert.True(errorsProp.GetArrayLength() > 0, "Expected at least one entry in 'errors' array");

// Each diagnostic should have file, line, column, code, message
foreach (var diagnostic in errorsProp.EnumerateArray())
{
Assert.True(diagnostic.TryGetProperty("code", out var codeProp), "Diagnostic missing 'code'");
Assert.False(string.IsNullOrWhiteSpace(codeProp.GetString()), "Diagnostic 'code' should not be empty");

Assert.True(diagnostic.TryGetProperty("message", out var msgProp), "Diagnostic missing 'message'");
Assert.False(string.IsNullOrWhiteSpace(msgProp.GetString()), "Diagnostic 'message' should not be empty");

// file/line/column should be present for Roslyn compiler errors
Assert.True(diagnostic.TryGetProperty("file", out var fileProp), "Diagnostic missing 'file'");
Assert.False(string.IsNullOrWhiteSpace(fileProp.GetString()), "Diagnostic 'file' should not be empty");

Assert.True(diagnostic.TryGetProperty("line", out var lineProp), "Diagnostic missing 'line'");
Assert.True(lineProp.GetInt32() > 0, "Diagnostic 'line' should be > 0");

Assert.True(diagnostic.TryGetProperty("column", out var colProp), "Diagnostic missing 'column'");
Assert.True(colProp.GetInt32() > 0, "Diagnostic 'column' should be > 0");
}
}
}
17 changes: 17 additions & 0 deletions DotNetMcp.Tests/Scenarios/McpScenarioClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,23 @@ public async Task<string> CallToolTextAsync(string toolName, Dictionary<string,
return text ?? string.Empty;
}

/// <summary>
/// Calls an MCP tool and returns the full <see cref="CallToolResult"/>, including any structured content.
/// </summary>
/// <param name="toolName">The name of the tool to call.</param>
/// <param name="args">The arguments to pass to the tool.</param>
/// <param name="cancellationToken">A cancellation token to cancel the operation.</param>
/// <returns>The full <see cref="CallToolResult"/> from the tool response.</returns>
public async Task<CallToolResult> CallToolAsync(string toolName, Dictionary<string, object?> args, CancellationToken cancellationToken)
{
if (_client is null)
{
throw new InvalidOperationException("MCP client not initialized.");
}

return await _client.CallToolAsync(toolName, args, cancellationToken: cancellationToken);
}

/// <summary>
/// Disposes the MCP client and releases all associated resources.
/// </summary>
Expand Down
26 changes: 26 additions & 0 deletions DotNetMcp.Tests/Tools/ConsolidatedProjectToolTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1807,6 +1807,32 @@ public async Task DotnetProject_Build_WithAllNewFlags_WiresAll()
MachineReadableCommandAssertions.AssertExecutedDotnetCommand(result, "dotnet build \"MyProject.csproj\" -c Release -f net8.0 --no-restore -v minimal -o \"out\"");
}

[Fact]
public async Task DotnetProject_Build_ReturnsStructuredContent()
{
// Build action should always return structured content (BuildResult) regardless of success/failure.
var callResult = await _tools.DotnetProject(
action: DotnetProjectAction.Build,
project: "MyProject.csproj",
configuration: "Release");

// Text content should still be present
var text = callResult.GetText();
Assert.NotNull(text);

// Structured content must be populated
Assert.True(callResult.StructuredContent.HasValue, "Build action should always return structured content");
var structuredJson = callResult.StructuredContent!.Value.GetRawText();
Assert.False(string.IsNullOrWhiteSpace(structuredJson));

// The structured content should be a valid BuildResult with success/errorCount/summary
using var doc = System.Text.Json.JsonDocument.Parse(structuredJson);
var root = doc.RootElement;
Assert.True(root.TryGetProperty("success", out _), "BuildResult should have 'success' field");
Assert.True(root.TryGetProperty("errorCount", out _), "BuildResult should have 'errorCount' field");
Assert.True(root.TryGetProperty("summary", out _), "BuildResult should have 'summary' field");
}

[Fact]
public async Task DotnetProject_Restore_WithVerbosity_WiresFlag()
{
Expand Down
Loading
Loading