File: Agents\DeprecatedMcpCommandScanner.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.Tool.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.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
using Aspire.Cli.Resources;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Cli.Agents;
 
/// <summary>
/// Scans for deprecated 'mcp start' command usage in agent environment configurations
/// and offers to update them to use the new 'agent mcp' command.
/// </summary>
internal sealed class DeprecatedMcpCommandScanner : IAgentEnvironmentScanner
{
    private readonly CliExecutionContext _executionContext;
    private readonly ILogger<DeprecatedMcpCommandScanner> _logger;
 
    // Define the agent config locations and their detection patterns
    private static readonly AgentConfigLocation[] s_configLocations =
    [
        new AgentConfigLocation("Claude Code", ".mcp.json", ConfigFormat.McpServersWithArgs, "mcpServers"),
        new AgentConfigLocation("VS Code", ".vscode/mcp.json", ConfigFormat.McpServersWithArgs, "servers"),
        new AgentConfigLocation("Copilot CLI", ".github/copilot/mcp.json", ConfigFormat.McpServersWithArgs, "servers"),
        new AgentConfigLocation("OpenCode", "opencode.jsonc", ConfigFormat.McpWithCommandArray, "mcp"),
    ];
 
    public DeprecatedMcpCommandScanner(CliExecutionContext executionContext, ILogger<DeprecatedMcpCommandScanner> logger)
    {
        ArgumentNullException.ThrowIfNull(executionContext);
        ArgumentNullException.ThrowIfNull(logger);
 
        _executionContext = executionContext;
        _logger = logger;
    }
 
    /// <inheritdoc />
    public Task ScanAsync(AgentEnvironmentScanContext context, CancellationToken cancellationToken)
    {
        _logger.LogDebug("Scanning for deprecated MCP command usage in agent configurations");
 
        // Debug: Write scanning info to temp file for diagnostics
        var debugPath = Path.Combine(Path.GetTempPath(), "aspire-deprecated-scan.log");
        File.AppendAllText(debugPath, $"[{DateTime.UtcNow:O}] Scanning context.RepositoryRoot: {context.RepositoryRoot.FullName}\n");
 
        foreach (var location in s_configLocations)
        {
            var configPath = Path.Combine(context.RepositoryRoot.FullName, location.RelativePath);
            File.AppendAllText(debugPath, $"[{DateTime.UtcNow:O}] Checking {location.AgentName}: {configPath}\n");
 
            if (!File.Exists(configPath))
            {
                File.AppendAllText(debugPath, $"[{DateTime.UtcNow:O}]   -> File does not exist\n");
                continue;
            }
 
            File.AppendAllText(debugPath, $"[{DateTime.UtcNow:O}]   -> File EXISTS!\n");
 
            try
            {
                var content = File.ReadAllText(configPath);
                File.AppendAllText(debugPath, $"[{DateTime.UtcNow:O}]   -> Content: {content.Substring(0, Math.Min(200, content.Length))}\n");
                var config = JsonNode.Parse(content)?.AsObject();
 
                if (config is null)
                {
                    File.AppendAllText(debugPath, $"[{DateTime.UtcNow:O}]   -> JSON parse returned null\n");
                    continue;
                }
 
                var hasDeprecated = HasDeprecatedMcpCommand(config, location);
                File.AppendAllText(debugPath, $"[{DateTime.UtcNow:O}]   -> HasDeprecatedMcpCommand: {hasDeprecated}\n");
 
                if (hasDeprecated)
                {
                    _logger.LogDebug("Found deprecated MCP command in {AgentName} config at {Path}", location.AgentName, configPath);
 
                    // Create an applicator to update this config
                    var applicator = CreateUpdateApplicator(location, configPath, config);
                    context.AddApplicator(applicator);
                    File.AppendAllText(debugPath, $"[{DateTime.UtcNow:O}]   -> Added applicator\n");
                }
            }
            catch (JsonException ex)
            {
                File.AppendAllText(debugPath, $"[{DateTime.UtcNow:O}]   -> JsonException: {ex.Message}\n");
                _logger.LogDebug(ex, "Failed to parse {AgentName} config at {Path}", location.AgentName, configPath);
            }
            catch (IOException ex)
            {
                File.AppendAllText(debugPath, $"[{DateTime.UtcNow:O}]   -> IOException: {ex.Message}\n");
                _logger.LogDebug(ex, "Failed to read {AgentName} config at {Path}", location.AgentName, configPath);
            }
        }
 
        return Task.CompletedTask;
    }
 
    /// <summary>
    /// Checks for deprecated MCP command patterns in the config.
    /// </summary>
    private static bool HasDeprecatedMcpCommand(JsonObject config, AgentConfigLocation location)
    {
        return location.Format switch
        {
            ConfigFormat.McpServersWithArgs => HasDeprecatedMcpServersArgs(config, location.ServersKey),
            ConfigFormat.McpWithCommandArray => HasDeprecatedMcpCommandArray(config, location.ServersKey),
            _ => false
        };
    }
 
    /// <summary>
    /// Checks for deprecated pattern: {serversKey}.aspire.args = ["mcp", "start"]
    /// Used by Claude Code (mcpServers), VS Code (servers), Copilot CLI (servers)
    /// </summary>
    private static bool HasDeprecatedMcpServersArgs(JsonObject config, string serversKey)
    {
        if (!config.TryGetPropertyValue(serversKey, out var serversNode) || serversNode is not JsonObject servers)
        {
            return false;
        }
 
        if (!servers.TryGetPropertyValue("aspire", out var aspireNode) || aspireNode is not JsonObject aspire)
        {
            return false;
        }
 
        if (!aspire.TryGetPropertyValue("args", out var argsNode) || argsNode is not JsonArray args)
        {
            return false;
        }
 
        // Check if args is ["mcp", "start"]
        if (args.Count >= 2 &&
            args[0]?.GetValue<string>() == "mcp" &&
            args[1]?.GetValue<string>() == "start")
        {
            return true;
        }
 
        return false;
    }
 
    /// <summary>
    /// Checks for deprecated pattern: {serversKey}.aspire.command = ["aspire", "mcp", "start"]
    /// Used by OpenCode
    /// </summary>
    private static bool HasDeprecatedMcpCommandArray(JsonObject config, string serversKey)
    {
        if (!config.TryGetPropertyValue(serversKey, out var mcpNode) || mcpNode is not JsonObject mcp)
        {
            return false;
        }
 
        if (!mcp.TryGetPropertyValue("aspire", out var aspireNode) || aspireNode is not JsonObject aspire)
        {
            return false;
        }
 
        if (!aspire.TryGetPropertyValue("command", out var commandNode) || commandNode is not JsonArray command)
        {
            return false;
        }
 
        // Check if command is ["aspire", "mcp", "start"]
        if (command.Count >= 3 &&
            command[0]?.GetValue<string>() == "aspire" &&
            command[1]?.GetValue<string>() == "mcp" &&
            command[2]?.GetValue<string>() == "start")
        {
            return true;
        }
 
        return false;
    }
 
    /// <summary>
    /// Creates an applicator that updates the config to use the new command format.
    /// </summary>
    private AgentEnvironmentApplicator CreateUpdateApplicator(AgentConfigLocation location, string configPath, JsonObject config)
    {
        var description = string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.DeprecatedConfigUpdate_Description, location.AgentName);
 
        return new AgentEnvironmentApplicator(
            description,
            async cancellationToken =>
            {
                await UpdateConfigAsync(location, configPath, config, cancellationToken);
            },
            promptGroup: McpInitPromptGroup.ConfigUpdates);
    }
 
    /// <summary>
    /// Updates the config file to use the new command format.
    /// </summary>
    private async Task UpdateConfigAsync(AgentConfigLocation location, string configPath, JsonObject config, CancellationToken cancellationToken)
    {
        _logger.LogDebug("Updating {AgentName} config at {Path}", location.AgentName, configPath);
 
        switch (location.Format)
        {
            case ConfigFormat.McpServersWithArgs:
                UpdateMcpServersArgs(config, location.ServersKey);
                break;
 
            case ConfigFormat.McpWithCommandArray:
                UpdateMcpCommandArray(config, location.ServersKey);
                break;
        }
 
        var jsonContent = JsonSerializer.Serialize(config, JsonSourceGenerationContext.Default.JsonObject);
        await File.WriteAllTextAsync(configPath, jsonContent, cancellationToken);
    }
 
    /// <summary>
    /// Updates {serversKey}.aspire.args from ["mcp", "start"] to ["agent", "mcp"]
    /// </summary>
    private static void UpdateMcpServersArgs(JsonObject config, string serversKey)
    {
        if (config.TryGetPropertyValue(serversKey, out var serversNode) &&
            serversNode is JsonObject servers &&
            servers.TryGetPropertyValue("aspire", out var aspireNode) &&
            aspireNode is JsonObject aspire)
        {
            aspire["args"] = new JsonArray("agent", "mcp");
        }
    }
 
    /// <summary>
    /// Updates {serversKey}.aspire.command from ["aspire", "mcp", "start"] to ["aspire", "agent", "mcp"]
    /// </summary>
    private static void UpdateMcpCommandArray(JsonObject config, string serversKey)
    {
        if (config.TryGetPropertyValue(serversKey, out var mcpNode) &&
            mcpNode is JsonObject mcp &&
            mcp.TryGetPropertyValue("aspire", out var aspireNode) &&
            aspireNode is JsonObject aspire)
        {
            aspire["command"] = new JsonArray("aspire", "agent", "mcp");
        }
    }
 
    /// <summary>
    /// Represents a known agent configuration file location.
    /// </summary>
    private sealed record AgentConfigLocation(string AgentName, string RelativePath, ConfigFormat Format, string ServersKey);
 
    /// <summary>
    /// Configuration file format for detecting deprecated commands.
    /// </summary>
    private enum ConfigFormat
    {
        /// <summary>
        /// Format: {serversKey}.aspire.args = ["mcp", "start"]
        /// </summary>
        McpServersWithArgs,
 
        /// <summary>
        /// Format: {serversKey}.aspire.command = ["aspire", "mcp", "start"]
        /// </summary>
        McpWithCommandArray
    }
}