From b25bed992ff91869920405907dbf9b1f316e474e Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 25 Feb 2026 21:09:13 +0000
Subject: [PATCH 1/3] Initial plan
From fcaa76efd0d8625780cb0b650a5cd47a88cf22d6 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 25 Feb 2026 21:29:05 +0000
Subject: [PATCH 2/3] Add dotnet_server_metrics tool with TelemetryFilter
message filter
Co-authored-by: jongalloway <68539+jongalloway@users.noreply.github.com>
---
.config/dotnet-tools.json | 10 +-
.../Tools/ServerMetricsToolTests.cs | 251 ++++++++++++++++++
.../Tools/ToolMetadataSerializationTests.cs | 6 +-
DotNetMcp/Actions/DotnetActions.cs | 13 +
DotNetMcp/DotNetCliTools.cs | 1 +
DotNetMcp/Program.cs | 32 +++
DotNetMcp/Server/ServerCapabilities.cs | 9 +
DotNetMcp/Telemetry/ToolMetricsAccumulator.cs | 108 ++++++++
DotNetMcp/Tools/Cli/DotNetCliTools.Core.cs | 4 +-
DotNetMcp/Tools/Cli/DotNetCliTools.Metrics.cs | 96 +++++++
DotNetMcp/Tools/Cli/DotNetCliTools.Misc.cs | 8 +-
11 files changed, 531 insertions(+), 7 deletions(-)
create mode 100644 DotNetMcp.Tests/Tools/ServerMetricsToolTests.cs
create mode 100644 DotNetMcp/Telemetry/ToolMetricsAccumulator.cs
create mode 100644 DotNetMcp/Tools/Cli/DotNetCliTools.Metrics.cs
diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index b0e38ab..bffb60c 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -1,5 +1,13 @@
{
"version": 1,
"isRoot": true,
- "tools": {}
+ "tools": {
+ "dotnet-ef": {
+ "version": "10.0.3",
+ "commands": [
+ "dotnet-ef"
+ ],
+ "rollForward": false
+ }
+ }
}
\ No newline at end of file
diff --git a/DotNetMcp.Tests/Tools/ServerMetricsToolTests.cs b/DotNetMcp.Tests/Tools/ServerMetricsToolTests.cs
new file mode 100644
index 0000000..762af3d
--- /dev/null
+++ b/DotNetMcp.Tests/Tools/ServerMetricsToolTests.cs
@@ -0,0 +1,251 @@
+using DotNetMcp;
+using DotNetMcp.Actions;
+using Microsoft.Extensions.Logging.Abstractions;
+using System.Text.Json;
+using Xunit;
+
+namespace DotNetMcp.Tests;
+
+///
+/// Tests for the dotnet_server_metrics tool and ToolMetricsAccumulator.
+///
+public class ServerMetricsToolTests
+{
+ private readonly DotNetCliTools _tools;
+ private readonly ToolMetricsAccumulator _accumulator;
+
+ public ServerMetricsToolTests()
+ {
+ _accumulator = new ToolMetricsAccumulator();
+ _tools = new DotNetCliTools(
+ NullLogger.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.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);
+ }
+}
diff --git a/DotNetMcp.Tests/Tools/ToolMetadataSerializationTests.cs b/DotNetMcp.Tests/Tools/ToolMetadataSerializationTests.cs
index cd66cf8..3965faa 100644
--- a/DotNetMcp.Tests/Tools/ToolMetadataSerializationTests.cs
+++ b/DotNetMcp.Tests/Tools/ToolMetadataSerializationTests.cs
@@ -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
///
[Fact]
public void AllToolMethods_HaveMcpServerToolAttribute()
@@ -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);
}
///
diff --git a/DotNetMcp/Actions/DotnetActions.cs b/DotNetMcp/Actions/DotnetActions.cs
index 817d9f2..7e69bba 100644
--- a/DotNetMcp/Actions/DotnetActions.cs
+++ b/DotNetMcp/Actions/DotnetActions.cs
@@ -301,6 +301,19 @@ public enum DotnetSdkAction
CacheMetrics
}
+///
+/// Actions for the dotnet_server_metrics tool.
+/// Exposes in-memory MCP tool invocation metrics collected by the telemetry filter.
+///
+public enum DotnetServerMetricsAction
+{
+ /// Return a JSON snapshot of per-tool invocation counts, average durations, and success/failure counts
+ Get,
+
+ /// Reset all accumulated metrics to zero
+ Reset
+}
+
///
/// Actions for the consolidated dotnet_dev_certs tool.
/// Manages developer certificates and user secrets.
diff --git a/DotNetMcp/DotNetCliTools.cs b/DotNetMcp/DotNetCliTools.cs
index c4bb124..1dd4318 100644
--- a/DotNetMcp/DotNetCliTools.cs
+++ b/DotNetMcp/DotNetCliTools.cs
@@ -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
diff --git a/DotNetMcp/Program.cs b/DotNetMcp/Program.cs
index e0553b8..4c80feb 100644
--- a/DotNetMcp/Program.cs
+++ b/DotNetMcp/Program.cs
@@ -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);
@@ -22,6 +25,11 @@
// This allows AI clients to run build/test/publish as async tasks with polling and cancellation.
builder.Services.AddSingleton();
+// 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
@@ -55,4 +63,28 @@
.WithResources()
.WithPrompts();
+// 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(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();
diff --git a/DotNetMcp/Server/ServerCapabilities.cs b/DotNetMcp/Server/ServerCapabilities.cs
index e7c775c..813c597 100644
--- a/DotNetMcp/Server/ServerCapabilities.cs
+++ b/DotNetMcp/Server/ServerCapabilities.cs
@@ -65,11 +65,20 @@ public sealed class ServerFeatureSupport
///
/// 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.
///
[JsonPropertyName("telemetry")]
public bool Telemetry { get; init; }
+ ///
+ /// 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.
+ ///
+ [JsonPropertyName("metrics")]
+ public bool Metrics { get; init; }
+
///
/// 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
diff --git a/DotNetMcp/Telemetry/ToolMetricsAccumulator.cs b/DotNetMcp/Telemetry/ToolMetricsAccumulator.cs
new file mode 100644
index 0000000..bc9db80
--- /dev/null
+++ b/DotNetMcp/Telemetry/ToolMetricsAccumulator.cs
@@ -0,0 +1,108 @@
+using System.Collections.Concurrent;
+using System.Text.Json.Serialization;
+
+namespace DotNetMcp;
+
+///
+/// Thread-safe in-memory accumulator for MCP tool invocation metrics.
+/// Tracks per-tool call counts, total durations, and success/failure counts.
+/// No personally identifiable information (PII) is stored — only tool names and timing data.
+///
+public sealed class ToolMetricsAccumulator
+{
+ private readonly ConcurrentDictionary _entries = new(StringComparer.Ordinal);
+
+ ///
+ /// Records a single tool invocation with its duration and outcome.
+ ///
+ /// The name of the invoked tool (e.g., "dotnet_project")
+ /// Elapsed wall-clock time of the invocation in milliseconds
+ /// Whether the invocation completed without throwing an exception
+ public void RecordInvocation(string toolName, long durationMs, bool success)
+ {
+ var entry = _entries.GetOrAdd(toolName, _ => new ToolMetricEntry());
+ entry.RecordInvocation(durationMs, success);
+ }
+
+ ///
+ /// Returns a read-only snapshot of all collected metrics, keyed by tool name.
+ ///
+ public IReadOnlyDictionary GetSnapshot()
+ {
+ var result = new Dictionary(StringComparer.Ordinal);
+ foreach (var (name, entry) in _entries)
+ {
+ result[name] = entry.GetSnapshot();
+ }
+ return result;
+ }
+
+ ///
+ /// Resets all accumulated metrics to zero.
+ ///
+ public void Reset() => _entries.Clear();
+}
+
+///
+/// Thread-safe mutable entry for a single tool's accumulated metrics.
+///
+internal sealed class ToolMetricEntry
+{
+ private long _invocationCount;
+ private long _totalDurationMs;
+ private long _successCount;
+ private long _failureCount;
+
+ public void RecordInvocation(long durationMs, bool success)
+ {
+ Interlocked.Increment(ref _invocationCount);
+ Interlocked.Add(ref _totalDurationMs, durationMs);
+ if (success)
+ Interlocked.Increment(ref _successCount);
+ else
+ Interlocked.Increment(ref _failureCount);
+ }
+
+ public ToolMetricSnapshot GetSnapshot()
+ {
+ long count = Interlocked.Read(ref _invocationCount);
+ long total = Interlocked.Read(ref _totalDurationMs);
+ long success = Interlocked.Read(ref _successCount);
+ long failure = Interlocked.Read(ref _failureCount);
+ double avgDuration = count > 0 ? (double)total / count : 0.0;
+ return new ToolMetricSnapshot(count, avgDuration, success, failure);
+ }
+}
+
+///
+/// An immutable snapshot of metrics for a single tool.
+///
+public sealed class ToolMetricSnapshot
+{
+ /// Total number of invocations recorded since last reset.
+ [JsonPropertyName("invocationCount")]
+ public long InvocationCount { get; }
+
+ /// Average duration in milliseconds across all recorded invocations.
+ [JsonPropertyName("avgDurationMs")]
+ public double AvgDurationMs { get; }
+
+ /// Number of invocations that completed without an exception.
+ [JsonPropertyName("successCount")]
+ public long SuccessCount { get; }
+
+ /// Number of invocations that threw an exception.
+ [JsonPropertyName("failureCount")]
+ public long FailureCount { get; }
+
+ ///
+ /// Initializes a new snapshot with the given values.
+ ///
+ public ToolMetricSnapshot(long invocationCount, double avgDurationMs, long successCount, long failureCount)
+ {
+ InvocationCount = invocationCount;
+ AvgDurationMs = Math.Round(avgDurationMs, 2);
+ SuccessCount = successCount;
+ FailureCount = failureCount;
+ }
+}
diff --git a/DotNetMcp/Tools/Cli/DotNetCliTools.Core.cs b/DotNetMcp/Tools/Cli/DotNetCliTools.Core.cs
index 5e7d24f..fc73d5d 100644
--- a/DotNetMcp/Tools/Cli/DotNetCliTools.Core.cs
+++ b/DotNetMcp/Tools/Cli/DotNetCliTools.Core.cs
@@ -17,17 +17,19 @@ public sealed partial class DotNetCliTools
private readonly ILogger _logger;
private readonly ConcurrencyManager _concurrencyManager;
private readonly ProcessSessionManager _processSessionManager;
+ private readonly ToolMetricsAccumulator? _metricsAccumulator;
// Constants for server capability discovery
private const string DefaultServerVersion = "1.0.0";
private const string ProtocolVersion = "2025-11-25";
- public DotNetCliTools(ILogger logger, ConcurrencyManager concurrencyManager, ProcessSessionManager processSessionManager)
+ public DotNetCliTools(ILogger logger, ConcurrencyManager concurrencyManager, ProcessSessionManager processSessionManager, ToolMetricsAccumulator? metricsAccumulator = null)
{
// DI guarantees logger is never null
_logger = logger!;
_concurrencyManager = concurrencyManager!;
_processSessionManager = processSessionManager!;
+ _metricsAccumulator = metricsAccumulator;
}
private async Task ExecuteDotNetCommand(string arguments, CancellationToken cancellationToken = default, string? workingDirectory = null)
diff --git a/DotNetMcp/Tools/Cli/DotNetCliTools.Metrics.cs b/DotNetMcp/Tools/Cli/DotNetCliTools.Metrics.cs
new file mode 100644
index 0000000..68d40a1
--- /dev/null
+++ b/DotNetMcp/Tools/Cli/DotNetCliTools.Metrics.cs
@@ -0,0 +1,96 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+
+namespace DotNetMcp;
+
+///
+/// Tool for retrieving and resetting in-memory MCP server metrics.
+/// Metrics are collected automatically by the telemetry filter wired into the MCP request pipeline.
+///
+public sealed partial class DotNetCliTools
+{
+ ///
+ /// Get or reset in-memory telemetry metrics for MCP tool invocations.
+ /// Returns per-tool counts, average durations, and success/failure rates.
+ /// Metrics are collected automatically via a message filter — no code changes needed in individual tools.
+ /// No PII is stored; only tool names and timing data are tracked.
+ ///
+ /// The metrics operation to perform: Get (return current snapshot) or Reset (clear all counters)
+ [McpServerTool(Title = "Server Metrics", ReadOnly = false, Idempotent = false, IconSource = "https://raw.githubusercontent.com/microsoft/fluentui-emoji/62ecdc0d7ca5c6df32148c169556bc8d3782fca4/assets/Bar%20Chart/Flat/bar_chart_flat.svg")]
+ [McpMeta("category", "telemetry")]
+ [McpMeta("priority", 5.0)]
+ [McpMeta("consolidatedTool", true)]
+ [McpMeta("actions", JsonValue = """["Get","Reset"]""")]
+ public partial Task DotnetServerMetrics(DotnetServerMetricsAction action)
+ {
+ if (_metricsAccumulator is null)
+ {
+ var error = ErrorResultFactory.ReturnCapabilityNotAvailable(
+ "server metrics",
+ "Metrics accumulator is not registered. Ensure ToolMetricsAccumulator is registered in the DI container.",
+ alternatives: null);
+ return Task.FromResult(StructuredContentHelper.ToCallToolResult(ErrorResultFactory.ToJson(error)));
+ }
+
+ switch (action)
+ {
+ case DotnetServerMetricsAction.Reset:
+ _metricsAccumulator.Reset();
+ var resetResponse = new MetricsResetResponse { Success = true, Message = "All metrics have been reset." };
+ return Task.FromResult(StructuredContentHelper.ToCallToolResult(ErrorResultFactory.ToJson(resetResponse)));
+
+ case DotnetServerMetricsAction.Get:
+ default:
+ var snapshot = _metricsAccumulator.GetSnapshot();
+ var metricsResponse = new ServerMetricsResponse
+ {
+ ToolMetrics = snapshot
+ .OrderBy(kv => kv.Key, StringComparer.Ordinal)
+ .ToDictionary(kv => kv.Key, kv => kv.Value),
+ TotalInvocations = snapshot.Values.Sum(m => m.InvocationCount),
+ TotalSuccesses = snapshot.Values.Sum(m => m.SuccessCount),
+ TotalFailures = snapshot.Values.Sum(m => m.FailureCount)
+ };
+ var json = ErrorResultFactory.ToJson(metricsResponse);
+ return Task.FromResult(StructuredContentHelper.ToCallToolResult(json, metricsResponse));
+ }
+ }
+}
+
+///
+/// JSON response for the dotnet_server_metrics Get action.
+///
+public sealed class ServerMetricsResponse
+{
+ /// Per-tool invocation metrics, keyed by tool name.
+ [JsonPropertyName("toolMetrics")]
+ public Dictionary ToolMetrics { get; init; } = new();
+
+ /// Total invocations across all tools since last reset.
+ [JsonPropertyName("totalInvocations")]
+ public long TotalInvocations { get; init; }
+
+ /// Total successful invocations across all tools since last reset.
+ [JsonPropertyName("totalSuccesses")]
+ public long TotalSuccesses { get; init; }
+
+ /// Total failed invocations across all tools since last reset.
+ [JsonPropertyName("totalFailures")]
+ public long TotalFailures { get; init; }
+}
+
+///
+/// JSON response for the dotnet_server_metrics Reset action.
+///
+public sealed class MetricsResetResponse
+{
+ /// Whether the reset succeeded.
+ [JsonPropertyName("success")]
+ public bool Success { get; init; }
+
+ /// Human-readable confirmation message.
+ [JsonPropertyName("message")]
+ public string Message { get; init; } = string.Empty;
+}
diff --git a/DotNetMcp/Tools/Cli/DotNetCliTools.Misc.cs b/DotNetMcp/Tools/Cli/DotNetCliTools.Misc.cs
index 8e65f1f..0d4407e 100644
--- a/DotNetMcp/Tools/Cli/DotNetCliTools.Misc.cs
+++ b/DotNetMcp/Tools/Cli/DotNetCliTools.Misc.cs
@@ -61,7 +61,8 @@ public async partial Task DotnetServerCapabilities()
"format",
"nuget",
"help",
- "efcore"
+ "efcore",
+ "telemetry"
},
Supports = new ServerFeatureSupport
{
@@ -69,6 +70,7 @@ public async partial Task DotnetServerCapabilities()
MachineReadable = true,
Cancellation = true,
Telemetry = true, // SDK v0.6+ supports request duration logging and OpenTelemetry semantic conventions
+ Metrics = true, // In-memory per-tool metrics via MCP message filter (dotnet_server_metrics tool)
AsyncTasks = true, // MCP Task support enabled: long-running operations (build, test, publish) can run as async tasks
Prompts = true, // Predefined prompt catalog: create_new_webapi, add_package_and_restore, run_tests_with_coverage
Elicitation = true // Elicitation for confirmation before destructive ops (Clean, solution Remove)
@@ -103,10 +105,11 @@ public partial Task DotnetServerInfo()
result.AppendLine();
result.AppendLine("FEATURES:");
- result.AppendLine(" • 11 Consolidated MCP Tools (8 functional + 3 utility)");
+ result.AppendLine(" • 12 Consolidated MCP Tools (8 functional + 4 utility)");
result.AppendLine(" • 4 MCP Resources (SDK, Runtime, Templates, Frameworks)");
result.AppendLine(" • 3 Predefined Prompts (create_new_webapi, add_package_and_restore, run_tests_with_coverage)");
result.AppendLine(" • Elicitation support: confirmation dialogs for destructive operations (Clean, solution Remove)");
+ result.AppendLine(" • Telemetry: in-memory metrics collected via MCP message filter (dotnet_server_metrics)");
result.AppendLine(" • Direct .NET SDK integration via NuGet packages");
result.AppendLine(" • Template Engine integration with caching (5-min TTL)");
result.AppendLine(" • Framework validation and LTS identification");
@@ -129,6 +132,7 @@ public partial Task DotnetServerInfo()
result.AppendLine(" • dotnet_help: Get help for any dotnet command");
result.AppendLine(" • dotnet_server_capabilities: Machine-readable server capabilities JSON");
result.AppendLine(" • dotnet_server_info: This detailed information output");
+ result.AppendLine(" • dotnet_server_metrics: In-memory telemetry metrics (Get/Reset) collected via message filter");
result.AppendLine();
result.AppendLine("CONCURRENCY SAFETY:");
From 17b570aa51160cc8a4ff1fc1fb96a53e9ff3c489 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 25 Feb 2026 21:29:25 +0000
Subject: [PATCH 3/3] Revert accidental dotnet-tools.json change
Co-authored-by: jongalloway <68539+jongalloway@users.noreply.github.com>
---
.config/dotnet-tools.json | 10 +---------
1 file changed, 1 insertion(+), 9 deletions(-)
diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index bffb60c..b0e38ab 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -1,13 +1,5 @@
{
"version": 1,
"isRoot": true,
- "tools": {
- "dotnet-ef": {
- "version": "10.0.3",
- "commands": [
- "dotnet-ef"
- ],
- "rollForward": false
- }
- }
+ "tools": {}
}
\ No newline at end of file