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