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
251 changes: 251 additions & 0 deletions DotNetMcp.Tests/Tools/ServerMetricsToolTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
using DotNetMcp;
using DotNetMcp.Actions;
using Microsoft.Extensions.Logging.Abstractions;
using System.Text.Json;
using Xunit;

namespace DotNetMcp.Tests;

/// <summary>
/// Tests for the dotnet_server_metrics tool and ToolMetricsAccumulator.
/// </summary>
public class ServerMetricsToolTests
{
private readonly DotNetCliTools _tools;
private readonly ToolMetricsAccumulator _accumulator;

public ServerMetricsToolTests()
{
_accumulator = new ToolMetricsAccumulator();
_tools = new DotNetCliTools(
NullLogger<DotNetCliTools>.Instance,
new ConcurrencyManager(),
new ProcessSessionManager(),
_accumulator);
}

// ---- ToolMetricsAccumulator unit tests ----

[Fact]
public void ToolMetricsAccumulator_InitialSnapshot_IsEmpty()
{
var snapshot = _accumulator.GetSnapshot();
Assert.Empty(snapshot);
}

[Fact]
public void ToolMetricsAccumulator_RecordInvocation_TracksCount()
{
_accumulator.RecordInvocation("dotnet_project", 100, success: true);
_accumulator.RecordInvocation("dotnet_project", 200, success: true);

var snapshot = _accumulator.GetSnapshot();

Assert.True(snapshot.TryGetValue("dotnet_project", out var entry));
Assert.Equal(2, entry.InvocationCount);
}

[Fact]
public void ToolMetricsAccumulator_RecordInvocation_TracksSuccessAndFailure()
{
_accumulator.RecordInvocation("dotnet_build", 50, success: true);
_accumulator.RecordInvocation("dotnet_build", 60, success: false);

var snapshot = _accumulator.GetSnapshot();

Assert.True(snapshot.TryGetValue("dotnet_build", out var entry));
Assert.Equal(1, entry.SuccessCount);
Assert.Equal(1, entry.FailureCount);
}

[Fact]
public void ToolMetricsAccumulator_RecordInvocation_ComputesAvgDuration()
{
_accumulator.RecordInvocation("dotnet_sdk", 100, success: true);
_accumulator.RecordInvocation("dotnet_sdk", 300, success: true);

var snapshot = _accumulator.GetSnapshot();

Assert.True(snapshot.TryGetValue("dotnet_sdk", out var entry));
Assert.Equal(200.0, entry.AvgDurationMs, precision: 1);
}

[Fact]
public void ToolMetricsAccumulator_RecordInvocation_TracksMultipleTools()
{
_accumulator.RecordInvocation("tool_a", 10, success: true);
_accumulator.RecordInvocation("tool_b", 20, success: true);

var snapshot = _accumulator.GetSnapshot();

Assert.Equal(2, snapshot.Count);
Assert.True(snapshot.ContainsKey("tool_a"));
Assert.True(snapshot.ContainsKey("tool_b"));
}

[Fact]
public void ToolMetricsAccumulator_Reset_ClearsAllEntries()
{
_accumulator.RecordInvocation("dotnet_project", 100, success: true);
_accumulator.RecordInvocation("dotnet_sdk", 200, success: true);

_accumulator.Reset();

var snapshot = _accumulator.GetSnapshot();
Assert.Empty(snapshot);
}

[Fact]
public void ToolMetricsAccumulator_AvgDuration_IsZeroWhenNoInvocations()
{
// Snapshot of a freshly-reset accumulator has no entries, so we can't test
// AvgDurationMs for a non-existent key; verify a single-invocation entry is correct
_accumulator.RecordInvocation("tool_x", 150, success: true);
var snapshot = _accumulator.GetSnapshot();
Assert.Equal(150.0, snapshot["tool_x"].AvgDurationMs, precision: 1);
}

// ---- DotnetServerMetrics tool tests ----

[Fact]
public async Task DotnetServerMetrics_Get_ReturnsEmptyMetricsWhenNoInvocations()
{
var result = (await _tools.DotnetServerMetrics(DotnetServerMetricsAction.Get)).GetText();

Assert.NotNull(result);
// Should be valid JSON
Assert.Contains("{", result);
Assert.Contains("}", result);
Assert.Contains("toolMetrics", result, StringComparison.OrdinalIgnoreCase);
Assert.Contains("totalInvocations", result, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public async Task DotnetServerMetrics_Get_ReturnsZeroTotalsWhenEmpty()
{
var result = (await _tools.DotnetServerMetrics(DotnetServerMetricsAction.Get)).GetText();

Assert.NotNull(result);
var doc = JsonDocument.Parse(result);
var root = doc.RootElement;

Assert.Equal(0, root.GetProperty("totalInvocations").GetInt64());
Assert.Equal(0, root.GetProperty("totalSuccesses").GetInt64());
Assert.Equal(0, root.GetProperty("totalFailures").GetInt64());
}

[Fact]
public async Task DotnetServerMetrics_Get_ReturnsAccumulatedMetrics()
{
_accumulator.RecordInvocation("dotnet_project", 100, success: true);
_accumulator.RecordInvocation("dotnet_project", 200, success: true);
_accumulator.RecordInvocation("dotnet_sdk", 50, success: false);

var result = (await _tools.DotnetServerMetrics(DotnetServerMetricsAction.Get)).GetText();

Assert.NotNull(result);
var doc = JsonDocument.Parse(result);
var root = doc.RootElement;

Assert.Equal(3, root.GetProperty("totalInvocations").GetInt64());
Assert.Equal(2, root.GetProperty("totalSuccesses").GetInt64());
Assert.Equal(1, root.GetProperty("totalFailures").GetInt64());
}

[Fact]
public async Task DotnetServerMetrics_Get_IncludesPerToolStats()
{
_accumulator.RecordInvocation("dotnet_project", 100, success: true);
_accumulator.RecordInvocation("dotnet_project", 300, success: true);

var result = (await _tools.DotnetServerMetrics(DotnetServerMetricsAction.Get)).GetText();

Assert.NotNull(result);
Assert.Contains("dotnet_project", result);
Assert.Contains("invocationCount", result, StringComparison.OrdinalIgnoreCase);
Assert.Contains("avgDurationMs", result, StringComparison.OrdinalIgnoreCase);

var doc = JsonDocument.Parse(result);
var toolMetrics = doc.RootElement.GetProperty("toolMetrics");
var projectMetrics = toolMetrics.GetProperty("dotnet_project");

Assert.Equal(2, projectMetrics.GetProperty("invocationCount").GetInt64());
Assert.Equal(200.0, projectMetrics.GetProperty("avgDurationMs").GetDouble(), precision: 1);
Assert.Equal(2, projectMetrics.GetProperty("successCount").GetInt64());
Assert.Equal(0, projectMetrics.GetProperty("failureCount").GetInt64());
}

[Fact]
public async Task DotnetServerMetrics_Reset_ClearsAllMetrics()
{
_accumulator.RecordInvocation("dotnet_project", 100, success: true);
_accumulator.RecordInvocation("dotnet_sdk", 200, success: true);

var resetResult = (await _tools.DotnetServerMetrics(DotnetServerMetricsAction.Reset)).GetText();

Assert.NotNull(resetResult);
Assert.Contains("success", resetResult, StringComparison.OrdinalIgnoreCase);
Assert.Contains("reset", resetResult, StringComparison.OrdinalIgnoreCase);

// Verify metrics are cleared
var getResult = (await _tools.DotnetServerMetrics(DotnetServerMetricsAction.Get)).GetText();
var doc = JsonDocument.Parse(getResult!);
Assert.Equal(0, doc.RootElement.GetProperty("totalInvocations").GetInt64());
}

[Fact]
public async Task DotnetServerMetrics_Reset_ReturnsSuccessJson()
{
var result = (await _tools.DotnetServerMetrics(DotnetServerMetricsAction.Reset)).GetText();

Assert.NotNull(result);
var doc = JsonDocument.Parse(result);
var root = doc.RootElement;

Assert.True(root.GetProperty("success").GetBoolean());
var message = root.GetProperty("message").GetString();
Assert.NotNull(message);
Assert.NotEmpty(message);
}

[Fact]
public async Task DotnetServerMetrics_WithoutAccumulator_ReturnsError()
{
// Create tools without an accumulator (simulates missing DI registration)
var toolsWithoutAccumulator = new DotNetCliTools(
NullLogger<DotNetCliTools>.Instance,
new ConcurrencyManager(),
new ProcessSessionManager(),
metricsAccumulator: null);

var result = (await toolsWithoutAccumulator.DotnetServerMetrics(DotnetServerMetricsAction.Get)).GetText();

Assert.NotNull(result);
Assert.Contains("Error", result, StringComparison.OrdinalIgnoreCase);
Assert.Contains("not", result, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public async Task DotnetServerMetrics_Get_IsCallToolResult()
{
var result = await _tools.DotnetServerMetrics(DotnetServerMetricsAction.Get);
Assert.NotNull(result);
Assert.NotEmpty(result.Content);
}

// ---- ToolMetricSnapshot tests ----

[Fact]
public void ToolMetricSnapshot_AvgDurationMs_IsRoundedToTwoDecimals()
{
_accumulator.RecordInvocation("tool_x", 100, success: true);
_accumulator.RecordInvocation("tool_x", 101, success: true);
_accumulator.RecordInvocation("tool_x", 102, success: true);

var snapshot = _accumulator.GetSnapshot();
var entry = snapshot["tool_x"];

// Average = 101, should be 101.0 (exactly 2 decimal places)
Assert.Equal(Math.Round(entry.AvgDurationMs, 2), entry.AvgDurationMs);
}
}
6 changes: 3 additions & 3 deletions DotNetMcp.Tests/Tools/ToolMetadataSerializationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ public class ToolMetadataSerializationTests
/// and their metadata is accessible.
/// After Phase 2: Only consolidated tools and utilities have [McpServerTool].
/// Expected: 8 consolidated tools (project, package, solution, ef, workload, tool, sdk, dev-certs)
/// + 3 utilities (server_capabilities, help, framework_info)
/// = 11 total tools
/// + 4 utilities (server_capabilities, help, server_info, server_metrics)
/// = 12 total tools
/// </summary>
[Fact]
public void AllToolMethods_HaveMcpServerToolAttribute()
Expand All @@ -37,7 +37,7 @@ public void AllToolMethods_HaveMcpServerToolAttribute()
// Assert
Assert.NotEmpty(toolMethods);
// Phase 2: Verify we have exactly the expected consolidated tools and utilities
Assert.Equal(11, toolMethods.Count);
Assert.Equal(12, toolMethods.Count);
}

/// <summary>
Expand Down
13 changes: 13 additions & 0 deletions DotNetMcp/Actions/DotnetActions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,19 @@ public enum DotnetSdkAction
CacheMetrics
}

/// <summary>
/// Actions for the dotnet_server_metrics tool.
/// Exposes in-memory MCP tool invocation metrics collected by the telemetry filter.
/// </summary>
public enum DotnetServerMetricsAction
{
/// <summary>Return a JSON snapshot of per-tool invocation counts, average durations, and success/failure counts</summary>
Get,

/// <summary>Reset all accumulated metrics to zero</summary>
Reset
}

/// <summary>
/// Actions for the consolidated dotnet_dev_certs tool.
/// Manages developer certificates and user secrets.
Expand Down
1 change: 1 addition & 0 deletions DotNetMcp/DotNetCliTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public sealed partial class DotNetCliTools
// - DotNetCliTools.DevCerts.Consolidated.cs
// - DotNetCliTools.EntityFramework.Consolidated.cs
// - DotNetCliTools.Misc.cs
// - DotNetCliTools.Metrics.cs
//
// Tools/Sdk:
// - DotNetCliTools.Sdk.Consolidated.cs
Expand Down
32 changes: 32 additions & 0 deletions DotNetMcp/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ModelContextProtocol;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.Diagnostics;

var builder = Host.CreateApplicationBuilder(args);

Expand All @@ -22,6 +25,11 @@
// This allows AI clients to run build/test/publish as async tasks with polling and cancellation.
builder.Services.AddSingleton<IMcpTaskStore, InMemoryMcpTaskStore>();

// Register ToolMetricsAccumulator for in-memory telemetry collection.
// The accumulator is also captured by the telemetry filter added below.
var metricsAccumulator = new ToolMetricsAccumulator();
builder.Services.AddSingleton(metricsAccumulator);

builder.Services.AddMcpServer(options =>
{
// Configure server implementation with .NET-themed icon
Expand Down Expand Up @@ -55,4 +63,28 @@
.WithResources<DotNetResources>()
.WithPrompts<DotNetPrompts>();

// Register the telemetry filter that intercepts every CallTool request to record
// invocation counts, durations, and success/failure rates — without modifying individual tools.
// The filter captures the shared metricsAccumulator instance via closure.
builder.Services.Configure<McpServerOptions>(options =>
{
options.Filters.Request.CallToolFilters.Add(next => async (context, ct) =>
{
var toolName = context.Params?.Name ?? "unknown";
var sw = Stopwatch.StartNew();
bool success = false;
try
{
var result = await next(context, ct);
success = true;
return result;
}
finally
{
sw.Stop();
metricsAccumulator.RecordInvocation(toolName, sw.ElapsedMilliseconds, success);
}
});
});

await builder.Build().RunAsync();
9 changes: 9 additions & 0 deletions DotNetMcp/Server/ServerCapabilities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,20 @@ public sealed class ServerFeatureSupport
/// <summary>
/// Whether the server supports telemetry reporting.
/// When enabled, the server emits request duration logs and follows OpenTelemetry semantic conventions (SDK v0.6+).
/// In-memory per-tool metrics are accessible via the dotnet_server_metrics tool.
/// See doc/telemetry.md for configuration details.
/// </summary>
[JsonPropertyName("telemetry")]
public bool Telemetry { get; init; }

/// <summary>
/// Whether the server collects and exposes in-memory tool invocation metrics.
/// When true, use dotnet_server_metrics to retrieve per-tool counts, average durations,
/// and success/failure rates collected by the MCP message filter.
/// </summary>
[JsonPropertyName("metrics")]
public bool Metrics { get; init; }

/// <summary>
/// Whether the server supports MCP Task augmentation for long-running operations such as
/// build, test, and publish. When true, AI clients can call supported tools as async tasks
Expand Down
Loading
Loading