| File: Agents\VsCode\VsCodeAgentEnvironmentScanner.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.Text.Json; using System.Text.Json.Nodes; using Aspire.Cli.Resources; using Microsoft.Extensions.Logging; namespace Aspire.Cli.Agents.VsCode; /// <summary> /// Scans for VS Code environments and provides an applicator to configure the Aspire MCP server. /// </summary> internal sealed class VsCodeAgentEnvironmentScanner : IAgentEnvironmentScanner { private const string VsCodeFolderName = ".vscode"; private const string McpConfigFileName = "mcp.json"; private const string AspireServerName = "aspire"; private readonly IVsCodeCliRunner _vsCodeCliRunner; private readonly CliExecutionContext _executionContext; private readonly IEnvironment _environment; private readonly ILogger<VsCodeAgentEnvironmentScanner> _logger; /// <summary> /// Initializes a new instance of <see cref="VsCodeAgentEnvironmentScanner"/>. /// </summary> /// <param name="vsCodeCliRunner">The VS Code CLI runner for checking if VS Code is installed.</param> /// <param name="executionContext">The CLI execution context for accessing environment variables and settings.</param> /// <param name="environment">The environment abstraction for reading environment variables.</param> /// <param name="logger">The logger for diagnostic output.</param> public VsCodeAgentEnvironmentScanner(IVsCodeCliRunner vsCodeCliRunner, CliExecutionContext executionContext, IEnvironment environment, ILogger<VsCodeAgentEnvironmentScanner> logger) { ArgumentNullException.ThrowIfNull(vsCodeCliRunner); ArgumentNullException.ThrowIfNull(executionContext); ArgumentNullException.ThrowIfNull(environment); ArgumentNullException.ThrowIfNull(logger); _vsCodeCliRunner = vsCodeCliRunner; _executionContext = executionContext; _environment = environment; _logger = logger; } /// <inheritdoc /> public async Task ScanAsync(AgentEnvironmentScanContext context, CancellationToken cancellationToken) { _logger.LogDebug("Starting VS Code environment scan in directory: {WorkingDirectory}", context.WorkingDirectory.FullName); _logger.LogDebug("Workspace root: {RepositoryRoot}", context.RepositoryRoot.FullName); _logger.LogDebug("Searching for .vscode folder..."); var vsCodeFolder = FindVsCodeFolder(context.WorkingDirectory, context.RepositoryRoot); if (vsCodeFolder is not null) { _logger.LogDebug("Found .vscode folder at: {VsCodeFolder}", vsCodeFolder.FullName); context.AddDetectedClient(AgentClientKind.VsCode); // Check if the aspire server is already configured if (!HasAspireServerConfigured(vsCodeFolder)) { // Found a .vscode folder - add an applicator to configure MCP _logger.LogDebug("Adding VS Code applicator for .vscode folder at: {VsCodeFolder}", vsCodeFolder.FullName); context.AddApplicator(CreateAspireApplicator(vsCodeFolder)); } else { _logger.LogDebug("Aspire MCP server is already configured in .vscode/mcp.json"); } } else if (await IsVsCodeAvailableAsync(cancellationToken).ConfigureAwait(false)) { _logger.LogDebug("No .vscode folder found, but VS Code is available on the system"); context.AddDetectedClient(AgentClientKind.VsCode); // No .vscode folder found, but VS Code is available // Use workspace root for new .vscode folder var targetVsCodeFolder = new DirectoryInfo(Path.Combine(context.RepositoryRoot.FullName, VsCodeFolderName)); _logger.LogDebug("Adding VS Code applicator for new .vscode folder at: {VsCodeFolder}", targetVsCodeFolder.FullName); context.AddApplicator(CreateAspireApplicator(targetVsCodeFolder)); } else { _logger.LogDebug("No .vscode folder found and VS Code is not available - skipping VS Code configuration"); } } /// <summary> /// Checks if VS Code is available on the machine. /// First checks for VS Code environment variables (low cost), /// then falls back to checking for the CLI executables. /// </summary> /// <param name="cancellationToken">A token to cancel the operation.</param> /// <returns>True if VS Code is available, false otherwise.</returns> private async Task<bool> IsVsCodeAvailableAsync(CancellationToken cancellationToken) { // First check environment variables (low cost) _logger.LogDebug("Checking for VS Code environment variables..."); if (HasVsCodeEnvironmentVariables()) { _logger.LogDebug("Found VS Code environment variables"); return true; } // Try VS Code stable _logger.LogDebug("Checking for VS Code stable CLI..."); var vsCodeVersion = await _vsCodeCliRunner.GetVersionAsync(new VsCodeRunOptions { UseInsiders = false }, cancellationToken).ConfigureAwait(false); if (vsCodeVersion is not null) { _logger.LogDebug("Found VS Code stable version: {Version}", vsCodeVersion); return true; } // Try VS Code Insiders _logger.LogDebug("Checking for VS Code Insiders CLI..."); var vsCodeInsidersVersion = await _vsCodeCliRunner.GetVersionAsync(new VsCodeRunOptions { UseInsiders = true }, cancellationToken).ConfigureAwait(false); if (vsCodeInsidersVersion is not null) { _logger.LogDebug("Found VS Code Insiders version: {Version}", vsCodeInsidersVersion); return true; } _logger.LogDebug("VS Code not found on the system"); return false; } /// <summary> /// Walks up the directory tree to find a .vscode folder. /// Stops if we go above the workspace root. /// Ignores the .vscode folder in the user's home directory (used for user settings, not workspace config). /// </summary> /// <param name="startDirectory">The directory to start searching from.</param> /// <param name="repositoryRoot">The workspace root to use as the boundary for searches.</param> private DirectoryInfo? FindVsCodeFolder(DirectoryInfo startDirectory, DirectoryInfo repositoryRoot) { var currentDirectory = startDirectory; var homeDirectory = _executionContext.HomeDirectory; while (currentDirectory is not null) { // Check for .vscode folder at current level, but ignore it if it's in the home directory // (the home directory's .vscode folder is for user settings, not workspace config) var vsCodePath = Path.Combine(currentDirectory.FullName, VsCodeFolderName); if (Directory.Exists(vsCodePath) && !string.Equals(currentDirectory.FullName, homeDirectory.FullName, StringComparison.OrdinalIgnoreCase)) { return new DirectoryInfo(vsCodePath); } // Stop if we've reached the workspace root without finding .vscode // (don't search above the workspace boundary) if (string.Equals(currentDirectory.FullName, repositoryRoot.FullName, StringComparison.OrdinalIgnoreCase)) { return null; } currentDirectory = currentDirectory.Parent; } return null; } /// <summary> /// Checks if any VS Code environment variables are present. /// </summary> private bool HasVsCodeEnvironmentVariables() { if (_environment.GetEnvironmentVariable("TERM_PROGRAM") == "vscode") { return true; } return false; } /// <summary> /// Checks if the .vscode folder contains an mcp.json file with an "aspire" server configured. /// </summary> /// <param name="vsCodeFolder">The .vscode folder to check.</param> /// <returns>True if the aspire server is already configured, false otherwise.</returns> private static bool HasAspireServerConfigured(DirectoryInfo vsCodeFolder) { var mcpConfigPath = Path.Combine(vsCodeFolder.FullName, McpConfigFileName); if (!File.Exists(mcpConfigPath)) { return false; } try { var content = File.ReadAllText(mcpConfigPath); var config = JsonNode.Parse(content)?.AsObject(); if (config is null) { return false; } if (config.TryGetPropertyValue("servers", out var serversNode) && serversNode is JsonObject servers) { return servers.ContainsKey(AspireServerName); } return false; } catch (JsonException) { // If the JSON is malformed, assume aspire is not configured return false; } } /// <summary> /// Creates an applicator for configuring the Aspire MCP server in the specified .vscode folder. /// </summary> private static AgentEnvironmentApplicator CreateAspireApplicator(DirectoryInfo vsCodeFolder) { return new AgentEnvironmentApplicator( VsCodeAgentEnvironmentScannerStrings.ApplicatorDescription, async cancellationToken => await ApplyAspireMcpConfigurationAsync(vsCodeFolder, cancellationToken)); } /// <summary> /// Creates or updates the mcp.json file in the .vscode folder with Aspire MCP configuration. /// </summary> private static async Task ApplyAspireMcpConfigurationAsync( DirectoryInfo vsCodeFolder, CancellationToken cancellationToken) { // Ensure the .vscode folder exists if (!vsCodeFolder.Exists) { vsCodeFolder.Create(); } var mcpConfigPath = Path.Combine(vsCodeFolder.FullName, McpConfigFileName); var config = await McpConfigFileHelper.ReadConfigAsync(mcpConfigPath, null, cancellationToken); // Ensure "servers" object exists if (!config.ContainsKey("servers") || config["servers"] is not JsonObject) { config["servers"] = new JsonObject(); } var servers = config["servers"]!.AsObject(); // Add or update the "aspire" server configuration servers[AspireServerName] = new JsonObject { ["type"] = "stdio", ["command"] = "aspire", ["args"] = new JsonArray("agent", "mcp") }; // Write the updated config with indentation using AOT-compatible serialization var jsonContent = JsonSerializer.Serialize(config, JsonSourceGenerationContext.Default.JsonObject); await File.WriteAllTextAsync(mcpConfigPath, jsonContent, cancellationToken); } }