File: Mcp\McpResourceToolRefreshService.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics;
using System.Text.Json;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Mcp.Tools;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
 
namespace Aspire.Cli.Mcp;
 
/// <summary>
/// Service responsible for refreshing resource-based MCP tools and sending tool list change notifications.
/// </summary>
internal sealed class McpResourceToolRefreshService : IMcpResourceToolRefreshService
{
    private readonly IAuxiliaryBackchannelMonitor _auxiliaryBackchannelMonitor;
    private readonly ILogger _logger;
    private readonly object _lock = new();
    private McpServer? _server;
    private ResourceToolMapSnapshot _snapshot = new(
        null,
        new Dictionary<string, ResourceToolEntry>(StringComparer.Ordinal));
    private bool _invalidated = true;
 
    public McpResourceToolRefreshService(
        IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor,
        ILogger<McpResourceToolRefreshService> logger)
    {
        _auxiliaryBackchannelMonitor = auxiliaryBackchannelMonitor;
        _logger = logger;
    }
 
    /// <inheritdoc/>
    public bool TryGetResourceToolMap(
        IAppHostAuxiliaryBackchannel? connection,
        out ResourceToolMapSnapshot snapshot)
    {
        lock (_lock)
        {
            if (_invalidated || !IsSameConnection(connection))
            {
                snapshot = null!;
                return false;
            }
 
            snapshot = _snapshot;
            return true;
        }
    }
 
    private bool IsSameConnection(IAppHostAuxiliaryBackchannel? connection)
        => ReferenceEquals(_snapshot.Connection, connection);
 
    /// <inheritdoc/>
    public void InvalidateToolMap()
    {
        lock (_lock)
        {
            _invalidated = true;
        }
    }
 
    /// <inheritdoc/>
    public void SetMcpServer(McpServer? server)
    {
        _server = server;
    }
 
    /// <inheritdoc/>
    public async Task SendToolsListChangedNotificationAsync(CancellationToken cancellationToken)
    {
        if (_server is { } server)
        {
            await server.SendNotificationAsync(NotificationMethods.ToolListChangedNotification, cancellationToken).ConfigureAwait(false);
        }
    }
 
    /// <inheritdoc/>
    public async Task<(ResourceToolMapSnapshot Snapshot, bool Changed)> RefreshResourceToolMapAsync(CancellationToken cancellationToken)
    {
        _logger.LogDebug("Refreshing resource tool map.");
 
        var refreshedMap = new Dictionary<string, ResourceToolEntry>(StringComparer.Ordinal);
 
        var connection = await AppHostConnectionHelper.GetSelectedConnectionAsync(_auxiliaryBackchannelMonitor, _logger, cancellationToken).ConfigureAwait(false);
 
        if (connection is not null)
        {
            try
            {
                var allResources = await connection.GetResourceSnapshotsAsync(includeHidden: true, cancellationToken).ConfigureAwait(false);
                var resourcesWithTools = allResources.Where(r => r.McpServer is not null && !McpToolHelpers.IsExcludedFromMcp(r)).ToList();
 
                _logger.LogDebug("Resources with MCP tools received: {Count}", resourcesWithTools.Count);
 
                foreach (var resource in resourcesWithTools)
                {
                    Debug.Assert(resource.McpServer is not null);
 
                    // Use DisplayName (the app-model name, e.g. "db1-mcp") rather than Name
                    // (the DCP runtime ID, e.g. "db1-mcp-ypnvhwvw") because the AppHost resolves
                    // resources by their app-model name in CallResourceMcpToolAsync.
                    var routedResourceName = resource.DisplayName ?? resource.Name;
 
                    foreach (var tool in resource.McpServer.Tools)
                    {
                        var exposedName = $"{routedResourceName.Replace("-", "_")}_{tool.Name}";
                        refreshedMap[exposedName] = new ResourceToolEntry(routedResourceName, tool);
 
                        _logger.LogDebug("{Tool}: {Description}", exposedName, tool.Description);
                    }
                }
            }
            catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
            {
                throw;
            }
            catch (Exception ex)
            {
                // Resource discovery failures should not hide the CLI's built-in MCP tools.
                _logger.LogDebug(ex, "Failed to refresh resource MCP tool routing map.");
            }
        }
        else
        {
            _logger.LogDebug("Unable to refresh resource tool map because there's no selected connection.");
        }
 
        lock (_lock)
        {
            var changed = !ToolMapsHaveEquivalentContracts(_snapshot.ToolMap, refreshedMap);
 
            _snapshot = new ResourceToolMapSnapshot(connection, refreshedMap);
            _invalidated = false;
            return (_snapshot, changed);
        }
    }
 
    private static bool ToolMapsHaveEquivalentContracts(
        IReadOnlyDictionary<string, ResourceToolEntry> previous,
        IReadOnlyDictionary<string, ResourceToolEntry> current)
    {
        if (previous.Count != current.Count)
        {
            return false;
        }
 
        foreach (var (exposedName, previousEntry) in previous)
        {
            if (!current.TryGetValue(exposedName, out var currentEntry) ||
                !ToolContractsAreEquivalent(
                    previousEntry.ToProtocolTool(exposedName),
                    currentEntry.ToProtocolTool(exposedName)))
            {
                return false;
            }
        }
 
        return true;
    }
 
    private static bool ToolContractsAreEquivalent(Tool previous, Tool current)
    {
        return string.Equals(previous.Name, current.Name, StringComparison.Ordinal) &&
            string.Equals(previous.Description, current.Description, StringComparison.Ordinal) &&
            JsonElement.DeepEquals(previous.InputSchema, current.InputSchema) &&
            JsonSchemasAreEquivalent(previous.OutputSchema, current.OutputSchema) &&
            ToolAnnotationsAreEquivalent(previous.Annotations, current.Annotations);
    }
 
    private static bool JsonSchemasAreEquivalent(JsonElement? previous, JsonElement? current)
    {
        if (previous is not { } previousValue)
        {
            return current is null;
        }
 
        return current is { } currentValue &&
            JsonElement.DeepEquals(previousValue, currentValue);
    }
 
    private static bool ToolAnnotationsAreEquivalent(ToolAnnotations? previous, ToolAnnotations? current)
    {
        if (ReferenceEquals(previous, current))
        {
            return true;
        }
 
        return previous is not null &&
            current is not null &&
            string.Equals(previous.Title, current.Title, StringComparison.Ordinal) &&
            previous.DestructiveHint == current.DestructiveHint &&
            previous.IdempotentHint == current.IdempotentHint &&
            previous.OpenWorldHint == current.OpenWorldHint &&
            previous.ReadOnlyHint == current.ReadOnlyHint;
    }
}