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
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ namespace DotNetMcp.Tests.ReleaseScenarios;
[Collection("ProcessWideStateTests")]
public class ColdCacheProjectLifecycleReleaseScenarioTests
{
private readonly ITestOutputHelper _output;

public ColdCacheProjectLifecycleReleaseScenarioTests(ITestOutputHelper output)
{
_output = output;
}

[ReleaseScenarioFact]
public async Task ReleaseScenario_ColdNuGetCache_Console_AddPackage_Restore_Build_Publish()
{
Expand Down Expand Up @@ -38,7 +45,7 @@ public async Task ReleaseScenario_ColdNuGetCache_Console_AddPackage_Restore_Buil
var projectPath = Path.Join(tempRoot.Path, "App.csproj");
Assert.True(File.Exists(projectPath), $"Expected App.csproj to exist at {projectPath}");

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

// Add a small public package to force a real restore against NuGet.
var addPackageText = await client.CallToolTextAsync(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ namespace DotNetMcp.Tests.ReleaseScenarios;
[Collection("ProcessWideStateTests")]
public class EfCoreSqliteMigrationsReleaseScenarioTests
{
private readonly ITestOutputHelper _output;

public EfCoreSqliteMigrationsReleaseScenarioTests(ITestOutputHelper output)
{
_output = output;
}

[ReleaseScenarioFact]
public async Task ReleaseScenario_EfCoreSqlite_MigrationsAdd_And_DatabaseUpdate()
{
Expand Down Expand Up @@ -38,7 +45,7 @@ public async Task ReleaseScenario_EfCoreSqlite_MigrationsAdd_And_DatabaseUpdate(
Assert.True(File.Exists(projectPath), $"Expected EfApp.csproj to exist at {projectPath}");

// Add EF Core packages via MCP.
await using var client = await McpScenarioClient.CreateAsync(cancellationToken);
await using var client = await McpScenarioClient.CreateAsync(cancellationToken, _output);

var addSqliteText = await client.CallToolTextAsync(
toolName: "dotnet_package",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,20 @@ namespace DotNetMcp.Tests.ReleaseScenarios;

public class LocalToolManifestReleaseScenarioTests
{
private readonly ITestOutputHelper _output;

public LocalToolManifestReleaseScenarioTests(ITestOutputHelper output)
{
_output = output;
}

[ReleaseScenarioFact]
public async Task ReleaseScenario_DotnetTool_CreateManifest_Install_Restore_List()
{
var cancellationToken = TestContext.Current.CancellationToken;
using var tempRoot = ScenarioHelpers.CreateTempDirectory(nameof(ReleaseScenario_DotnetTool_CreateManifest_Install_Restore_List));

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

var createManifestText = await client.CallToolTextAsync(
toolName: "dotnet_tool",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,19 @@ namespace DotNetMcp.Tests.ReleaseScenarios;

public class ServerConcurrencyStressReleaseScenarioTests
{
private readonly ITestOutputHelper _output;

public ServerConcurrencyStressReleaseScenarioTests(ITestOutputHelper output)
{
_output = output;
}

[ReleaseScenarioFact]
public async Task ReleaseScenario_ServerConcurrency_ManyParallelSdkInfoCalls()
{
var cancellationToken = TestContext.Current.CancellationToken;

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

const int callCount = 64;

Expand Down
13 changes: 10 additions & 3 deletions DotNetMcp.Tests/Scenarios/BackgroundRunScenarioTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ namespace DotNetMcp.Tests.Scenarios;

public class BackgroundRunScenarioTests
{
private readonly ITestOutputHelper _output;

public BackgroundRunScenarioTests(ITestOutputHelper output)
{
_output = output;
}

private static async Task<string> WaitForLogsContainingAsync(
McpScenarioClient client,
string sessionId,
Expand Down Expand Up @@ -77,7 +84,7 @@ public async Task Scenario_BackgroundRun_StartStopProcess_Success()
";
File.WriteAllText(programPath, programContent);

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

// Build the project
var buildText = await client.CallToolTextAsync(
Expand Down Expand Up @@ -180,7 +187,7 @@ public async Task Scenario_BackgroundRun_RetrieveLogs_Success()
";
File.WriteAllText(programPath, programContent);

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

// Build the project
var buildText = await client.CallToolTextAsync(
Expand Down Expand Up @@ -265,7 +272,7 @@ await client.CallToolTextAsync(
public async Task Scenario_BackgroundRun_Logs_InvalidSessionId_ReturnsError()
{
var cancellationToken = TestContext.Current.CancellationToken;
await using var client = await McpScenarioClient.CreateAsync(cancellationToken);
await using var client = await McpScenarioClient.CreateAsync(cancellationToken, _output);

// Try to retrieve logs for a non-existent session
var logsText = await client.CallToolTextAsync(
Expand Down
9 changes: 8 additions & 1 deletion DotNetMcp.Tests/Scenarios/BuildErrorScenarioTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ namespace DotNetMcp.Tests.Scenarios;

public class BuildErrorScenarioTests
{
private readonly ITestOutputHelper _output;

public BuildErrorScenarioTests(ITestOutputHelper output)
{
_output = output;
}

[ScenarioFact]
public async Task Scenario_DotnetProject_Build_WithCompileError_ReturnsMachineReadableError()
{
Expand All @@ -32,7 +39,7 @@ public async Task Scenario_DotnetProject_Build_WithCompileError_ReturnsMachineRe
Assert.True(File.Exists(programPath), "Expected Program.cs to exist");
await File.WriteAllTextAsync(programPath, "IAmABogusType bogus = new IAmABogusType();\n", cancellationToken);

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

var result = await client.CallToolAsync(
toolName: "dotnet_project",
Expand Down
9 changes: 8 additions & 1 deletion DotNetMcp.Tests/Scenarios/ConsoleScenarioTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ namespace DotNetMcp.Tests.Scenarios;

public class ConsoleScenarioTests
{
private readonly ITestOutputHelper _output;

public ConsoleScenarioTests(ITestOutputHelper output)
{
_output = output;
}

[ScenarioFact]
public async Task Scenario_ConsoleProject_AddPackageAndBuild_Release()
{
Expand All @@ -22,7 +29,7 @@ public async Task Scenario_ConsoleProject_AddPackageAndBuild_Release()
var projectPath = Path.Join(tempRoot.Path, "ConsoleApp.csproj");
Assert.True(File.Exists(projectPath), $"Expected ConsoleApp.csproj to exist at {projectPath}");

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

// Add a package via MCP.
var addPackageText = await client.CallToolTextAsync(
Expand Down
68 changes: 59 additions & 9 deletions DotNetMcp.Tests/Scenarios/McpScenarioClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,27 +11,39 @@ namespace DotNetMcp.Tests.Scenarios;
/// over stdio for end-to-end integration tests.
/// </summary>
/// <remarks>
/// Use <see cref="CreateAsync(CancellationToken)"/> to create an instance, which will launch
/// the MCP server process and connect an <see cref="McpClient"/> over stdio. Call
/// <see cref="DisposeAsync"/> when finished to shut down the underlying client and release
/// Use <see cref="CreateAsync(CancellationToken, ITestOutputHelper?)"/> to create an instance,
/// which will launch the MCP server process and connect an <see cref="McpClient"/> over stdio.
/// Pass an <see cref="ITestOutputHelper"/> to capture diagnostic output (server command, tool
/// calls, and response snippets) tied to the individual test in CI logs.
/// Call <see cref="DisposeAsync"/> when finished to shut down the underlying client and release
/// associated resources, including the logger factory and server process.
/// </remarks>
internal sealed class McpScenarioClient : IAsyncDisposable
{
private const int MaxResponseLogLength = 500;

private readonly ILoggerFactory _loggerFactory;
private readonly ITestOutputHelper? _output;
private McpClient? _client;

private McpScenarioClient(ILoggerFactory loggerFactory)
private McpScenarioClient(ILoggerFactory loggerFactory, ITestOutputHelper? output)
{
_loggerFactory = loggerFactory;
_output = output;
}

/// <summary>
/// Creates and starts a new MCP scenario client connected to the dotnet-mcp server.
/// </summary>
/// <param name="cancellationToken">A cancellation token to cancel the operation.</param>
/// <param name="output">
/// Optional xUnit output helper. When provided, diagnostic information (server start
/// command, tool calls, and response snippets) is written to the per-test output channel
/// so that CI logs for failing tests include actionable context. Arg values are never
/// logged to avoid inadvertently printing secrets.
/// </param>
/// <returns>A connected McpScenarioClient instance.</returns>
public static async Task<McpScenarioClient> CreateAsync(CancellationToken cancellationToken)
public static async Task<McpScenarioClient> CreateAsync(CancellationToken cancellationToken, ITestOutputHelper? output = null)
{
var loggerFactory = LoggerFactory.Create(builder =>
{
Expand All @@ -42,7 +54,7 @@ public static async Task<McpScenarioClient> CreateAsync(CancellationToken cancel
builder.SetMinimumLevel(LogLevel.Warning);
});

var host = new McpScenarioClient(loggerFactory);
var host = new McpScenarioClient(loggerFactory, output);
await host.StartAsync(cancellationToken);
return host;
}
Expand All @@ -51,6 +63,8 @@ private async Task StartAsync(CancellationToken cancellationToken)
{
var serverPath = GetServerExecutablePath();

_output?.WriteLine($"[MCP] Server command: {serverPath.command} {string.Join(" ", serverPath.arguments)}");

var transportOptions = new StdioClientTransportOptions
{
Command = serverPath.command,
Expand All @@ -64,6 +78,8 @@ private async Task StartAsync(CancellationToken cancellationToken)
transport,
loggerFactory: _loggerFactory,
cancellationToken: cancellationToken);

_output?.WriteLine("[MCP] Server connected.");
}

/// <summary>
Expand All @@ -80,9 +96,14 @@ public async Task<string> CallToolTextAsync(string toolName, Dictionary<string,
throw new InvalidOperationException("MCP client not initialized.");
}

LogToolCall(toolName, args);

var result = await _client.CallToolAsync(toolName, args, cancellationToken: cancellationToken);
var text = result.Content.OfType<TextContentBlock>().FirstOrDefault()?.Text;
return text ?? string.Empty;
var text = result.Content.OfType<TextContentBlock>().FirstOrDefault()?.Text ?? string.Empty;

LogToolResponse(toolName, text);

return text;
}

/// <summary>
Expand All @@ -99,7 +120,36 @@ public async Task<CallToolResult> CallToolAsync(string toolName, Dictionary<stri
throw new InvalidOperationException("MCP client not initialized.");
}

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

var result = await _client.CallToolAsync(toolName, args, cancellationToken: cancellationToken);

var text = result.Content.OfType<TextContentBlock>().FirstOrDefault()?.Text ?? string.Empty;
LogToolResponse(toolName, text);

return result;
}

/// <summary>
/// Logs the tool call with its name and argument keys (values are omitted to avoid leaking secrets).
/// </summary>
private void LogToolCall(string toolName, Dictionary<string, object?> args)
{
if (_output is null) return;
var keys = string.Join(", ", args.Keys);
_output.WriteLine($"[MCP] → {toolName} (args: {{{keys}}})");
}

/// <summary>
/// Logs a truncated snippet of the tool response.
/// </summary>
private void LogToolResponse(string toolName, string text)
{
if (_output is null) return;
var snippet = text.Length <= MaxResponseLogLength
? text
: string.Concat(text.AsSpan(0, MaxResponseLogLength), $"... [{text.Length - MaxResponseLogLength} chars truncated]");
_output.WriteLine($"[MCP] ← {toolName}: {snippet}");
}

/// <summary>
Expand Down
11 changes: 9 additions & 2 deletions DotNetMcp.Tests/Scenarios/PackageAndReferenceScenarioTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ namespace DotNetMcp.Tests.Scenarios;

public class PackageAndReferenceScenarioTests
{
private readonly ITestOutputHelper _output;

public PackageAndReferenceScenarioTests(ITestOutputHelper output)
{
_output = output;
}

[ScenarioFact]
public async Task Scenario_DotnetPackage_AddInvalidPackage_ReturnsMachineReadableError()
{
Expand All @@ -22,7 +29,7 @@ public async Task Scenario_DotnetPackage_AddInvalidPackage_ReturnsMachineReadabl
var projectPath = Path.Join(tempRoot.Path, "TempProj.csproj");
Assert.True(File.Exists(projectPath), $"Expected TempProj.csproj to exist at {projectPath}");

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

var text = await client.CallToolTextAsync(
toolName: "dotnet_package",
Expand Down Expand Up @@ -64,7 +71,7 @@ public async Task Scenario_ProjectReferenceFlow_AddReferenceAndBuildSolution_Rel
Assert.True(File.Exists(libAProj));
Assert.True(File.Exists(libBProj));

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

// Create solution via MCP.
var slnCreateText = await client.CallToolTextAsync(
Expand Down
13 changes: 10 additions & 3 deletions DotNetMcp.Tests/Scenarios/SdkContractScenarioTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,19 @@ namespace DotNetMcp.Tests.Scenarios;

public class SdkContractScenarioTests
{
private readonly ITestOutputHelper _output;

public SdkContractScenarioTests(ITestOutputHelper output)
{
_output = output;
}

[ScenarioFact]
public async Task Scenario_DotnetSdk_ListSdks_MachineReadable_Success()
{
var cancellationToken = TestContext.Current.CancellationToken;

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

var text = await client.CallToolTextAsync(
toolName: "dotnet_sdk",
Expand All @@ -30,7 +37,7 @@ public async Task Scenario_DotnetSdk_SearchTemplates_MissingSearchTerm_ReturnsVa
{
var cancellationToken = TestContext.Current.CancellationToken;

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

var text = await client.CallToolTextAsync(
toolName: "dotnet_sdk",
Expand All @@ -49,7 +56,7 @@ public async Task Scenario_DotnetSdk_ListTemplatePacks_MachineReadable_Success()
{
var cancellationToken = TestContext.Current.CancellationToken;

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

var text = await client.CallToolTextAsync(
toolName: "dotnet_sdk",
Expand Down
9 changes: 8 additions & 1 deletion DotNetMcp.Tests/Scenarios/SecretsRedactionScenarioTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ namespace DotNetMcp.Tests.Scenarios;

public class SecretsRedactionScenarioTests
{
private readonly ITestOutputHelper _output;

public SecretsRedactionScenarioTests(ITestOutputHelper output)
{
_output = output;
}

[ScenarioFact]
public async Task Scenario_UserSecrets_NoSecretLeak_InOtherToolOutputs()
{
Expand All @@ -24,7 +31,7 @@ public async Task Scenario_UserSecrets_NoSecretLeak_InOtherToolOutputs()
var projectPath = Path.Join(tempRoot.Path, "SecretsProj.csproj");
Assert.True(File.Exists(projectPath), $"Expected SecretsProj.csproj to exist at {projectPath}");

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

// Initialize + set a secret for the main project.
var initText = await client.CallToolTextAsync(
Expand Down
Loading
Loading