diff --git a/src/Aspire.Cli/Backchannel/AuxiliaryBackchannelMonitor.cs b/src/Aspire.Cli/Backchannel/AuxiliaryBackchannelMonitor.cs index 37b56596bed..8c7033be5d5 100644 --- a/src/Aspire.Cli/Backchannel/AuxiliaryBackchannelMonitor.cs +++ b/src/Aspire.Cli/Backchannel/AuxiliaryBackchannelMonitor.cs @@ -37,6 +37,75 @@ internal sealed class AuxiliaryBackchannelMonitor( /// public string? SelectedAppHostPath { get; set; } + /// + /// Gets the currently selected AppHost connection based on the selection logic. + /// + public AppHostConnection? SelectedConnection + { + get + { + var connections = _connections.Values.ToList(); + + if (connections.Count == 0) + { + return null; + } + + // Check if a specific AppHost was selected + if (!string.IsNullOrEmpty(SelectedAppHostPath)) + { + var selectedConnection = connections.FirstOrDefault(c => + c.AppHostInfo?.AppHostPath != null && + string.Equals(Path.GetFullPath(c.AppHostInfo.AppHostPath), Path.GetFullPath(SelectedAppHostPath), StringComparison.OrdinalIgnoreCase)); + + if (selectedConnection != null) + { + return selectedConnection; + } + + // Clear the selection since the AppHost is no longer available + SelectedAppHostPath = null; + } + + // Look for in-scope connections + var inScopeConnections = connections.Where(c => c.IsInScope).ToList(); + + if (inScopeConnections.Count == 1) + { + return inScopeConnections[0]; + } + + // Fall back to the first available connection + return connections.FirstOrDefault(); + } + } + + /// + /// Gets all connections that are within the scope of the specified working directory. + /// + public IReadOnlyList GetConnectionsForWorkingDirectory(DirectoryInfo workingDirectory) + { + return _connections.Values + .Where(c => IsAppHostInScopeOfDirectory(c.AppHostInfo?.AppHostPath, workingDirectory.FullName)) + .ToList(); + } + + private static bool IsAppHostInScopeOfDirectory(string? appHostPath, string workingDirectory) + { + if (string.IsNullOrEmpty(appHostPath)) + { + return false; + } + + // Normalize the paths for comparison + var normalizedWorkingDirectory = Path.GetFullPath(workingDirectory); + var normalizedAppHostPath = Path.GetFullPath(appHostPath); + + // Check if the AppHost path is within the working directory + var relativePath = Path.GetRelativePath(normalizedWorkingDirectory, normalizedAppHostPath); + return !relativePath.StartsWith("..", StringComparison.Ordinal) && !Path.IsPathRooted(relativePath); + } + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { try diff --git a/src/Aspire.Cli/Backchannel/IAuxiliaryBackchannelMonitor.cs b/src/Aspire.Cli/Backchannel/IAuxiliaryBackchannelMonitor.cs index 9a7b18efec7..6d59ef95e3c 100644 --- a/src/Aspire.Cli/Backchannel/IAuxiliaryBackchannelMonitor.cs +++ b/src/Aspire.Cli/Backchannel/IAuxiliaryBackchannelMonitor.cs @@ -17,4 +17,17 @@ internal interface IAuxiliaryBackchannelMonitor /// Gets or sets the path to the selected AppHost. When set, this AppHost will be used for MCP operations. /// string? SelectedAppHostPath { get; set; } + + /// + /// Gets the currently selected AppHost connection based on the selection logic. + /// Returns the explicitly selected AppHost, or the single in-scope AppHost, or null if none available. + /// + AppHostConnection? SelectedConnection { get; } + + /// + /// Gets all connections that are within the scope of the specified working directory. + /// + /// The working directory to check against. + /// A list of in-scope connections. + IReadOnlyList GetConnectionsForWorkingDirectory(DirectoryInfo workingDirectory); } diff --git a/src/Aspire.Cli/Commands/McpCommand.cs b/src/Aspire.Cli/Commands/McpCommand.cs index 189a8e36720..041399479f2 100644 --- a/src/Aspire.Cli/Commands/McpCommand.cs +++ b/src/Aspire.Cli/Commands/McpCommand.cs @@ -8,6 +8,7 @@ using Aspire.Cli.Configuration; using Aspire.Cli.Git; using Aspire.Cli.Interaction; +using Aspire.Cli.Packaging; using Aspire.Cli.Resources; using Aspire.Cli.Utils; using Microsoft.Extensions.Logging; @@ -25,12 +26,13 @@ public McpCommand( ILoggerFactory loggerFactory, ILogger logger, IAgentEnvironmentDetector agentEnvironmentDetector, - IGitRepository gitRepository) + IGitRepository gitRepository, + IPackagingService packagingService) : base("mcp", McpCommandStrings.Description, features, updateNotifier, executionContext, interactionService) { ArgumentNullException.ThrowIfNull(interactionService); - var startCommand = new McpStartCommand(interactionService, features, updateNotifier, executionContext, auxiliaryBackchannelMonitor, loggerFactory, logger); + var startCommand = new McpStartCommand(interactionService, features, updateNotifier, executionContext, auxiliaryBackchannelMonitor, loggerFactory, logger, packagingService); Subcommands.Add(startCommand); var initCommand = new McpInitCommand(interactionService, features, updateNotifier, executionContext, agentEnvironmentDetector, gitRepository); diff --git a/src/Aspire.Cli/Commands/McpStartCommand.cs b/src/Aspire.Cli/Commands/McpStartCommand.cs index 3138d5ba557..63dd431e8d8 100644 --- a/src/Aspire.Cli/Commands/McpStartCommand.cs +++ b/src/Aspire.Cli/Commands/McpStartCommand.cs @@ -7,6 +7,7 @@ using Aspire.Cli.Configuration; using Aspire.Cli.Interaction; using Aspire.Cli.Mcp; +using Aspire.Cli.Packaging; using Aspire.Cli.Resources; using Aspire.Cli.Utils; using Microsoft.Extensions.Logging; @@ -25,7 +26,7 @@ internal sealed class McpStartCommand : BaseCommand private readonly ILoggerFactory _loggerFactory; private readonly ILogger _logger; - public McpStartCommand(IInteractionService interactionService, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor, ILoggerFactory loggerFactory, ILogger logger) + public McpStartCommand(IInteractionService interactionService, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor, ILoggerFactory loggerFactory, ILogger logger, IPackagingService packagingService) : base("start", McpCommandStrings.StartCommand_Description, features, updateNotifier, executionContext, interactionService) { _auxiliaryBackchannelMonitor = auxiliaryBackchannelMonitor; @@ -41,7 +42,9 @@ public McpStartCommand(IInteractionService interactionService, IFeatures feature ["list_traces"] = new ListTracesTool(), ["list_trace_structured_logs"] = new ListTraceStructuredLogsTool(), ["select_apphost"] = new SelectAppHostTool(auxiliaryBackchannelMonitor, executionContext), - ["list_apphosts"] = new ListAppHostsTool(auxiliaryBackchannelMonitor, executionContext) + ["list_apphosts"] = new ListAppHostsTool(auxiliaryBackchannelMonitor, executionContext), + ["list_integrations"] = new ListIntegrationsTool(packagingService, executionContext), + ["get_integration_docs"] = new GetIntegrationDocsTool() }; } @@ -100,8 +103,8 @@ private async ValueTask HandleCallToolAsync(RequestContext +/// MCP tool for getting documentation for a specific Aspire hosting integration. +/// +internal sealed class GetIntegrationDocsTool : CliMcpTool +{ + public override string Name => "get_integration_docs"; + + public override string Description => "Gets documentation for a specific Aspire hosting integration package. Use this tool to get detailed information about how to use an integration within the AppHost."; + + public override JsonElement GetInputSchema() + { + return JsonDocument.Parse(""" + { + "type": "object", + "properties": { + "packageId": { + "type": "string", + "description": "The NuGet package ID of the integration (e.g., 'Aspire.Hosting.Redis')." + }, + "packageVersion": { + "type": "string", + "description": "The version of the package (e.g., '9.0.0')." + } + }, + "required": ["packageId", "packageVersion"], + "additionalProperties": false, + "description": "Gets documentation for a specific Aspire hosting integration. Requires the package ID and version." + } + """).RootElement; + } + + public override ValueTask CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary? arguments, CancellationToken cancellationToken) + { + // This tool does not use the MCP client as it operates locally + _ = mcpClient; + _ = cancellationToken; + + if (arguments == null) + { + return ValueTask.FromResult(new CallToolResult + { + IsError = true, + Content = [new TextContentBlock { Text = "Arguments are required." }] + }); + } + + if (!arguments.TryGetValue("packageId", out var packageIdElement)) + { + return ValueTask.FromResult(new CallToolResult + { + IsError = true, + Content = [new TextContentBlock { Text = "The 'packageId' parameter is required." }] + }); + } + + var packageId = packageIdElement.GetString(); + if (string.IsNullOrEmpty(packageId)) + { + return ValueTask.FromResult(new CallToolResult + { + IsError = true, + Content = [new TextContentBlock { Text = "The 'packageId' parameter cannot be empty." }] + }); + } + + if (!arguments.TryGetValue("packageVersion", out var packageVersionElement)) + { + return ValueTask.FromResult(new CallToolResult + { + IsError = true, + Content = [new TextContentBlock { Text = "The 'packageVersion' parameter is required." }] + }); + } + + var packageVersion = packageVersionElement.GetString(); + if (string.IsNullOrEmpty(packageVersion)) + { + return ValueTask.FromResult(new CallToolResult + { + IsError = true, + Content = [new TextContentBlock { Text = "The 'packageVersion' parameter cannot be empty." }] + }); + } + + var content = $""" + Instructions for the {packageId} integration can be downloaded from: + + https://www.nuget.org/packages/{packageId}/{packageVersion} + + Review this documentation for instructions on how to use this package within the apphost. Refer to linked documentation for additional information. + """; + + return ValueTask.FromResult(new CallToolResult + { + Content = [new TextContentBlock { Text = content }] + }); + } +} diff --git a/src/Aspire.Cli/Mcp/ListIntegrationsTool.cs b/src/Aspire.Cli/Mcp/ListIntegrationsTool.cs new file mode 100644 index 00000000000..432f9221587 --- /dev/null +++ b/src/Aspire.Cli/Mcp/ListIntegrationsTool.cs @@ -0,0 +1,151 @@ +// 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.Serialization; +using Aspire.Cli.Packaging; +using ModelContextProtocol.Protocol; +using Semver; + +namespace Aspire.Cli.Mcp; + +/// +/// Represents an Aspire hosting integration package. +/// +internal sealed class Integration +{ + /// + /// Gets or sets the friendly name of the integration (e.g., "Redis", "PostgreSQL"). + /// + [JsonPropertyName("name")] + public required string Name { get; set; } + + /// + /// Gets or sets the NuGet package ID. + /// + [JsonPropertyName("packageId")] + public required string PackageId { get; set; } + + /// + /// Gets or sets the package version. + /// + [JsonPropertyName("version")] + public required string Version { get; set; } +} + +/// +/// Represents the response from the list_integrations tool. +/// +internal sealed class ListIntegrationsResponse +{ + /// + /// Gets or sets the list of available integrations. + /// + [JsonPropertyName("integrations")] + public required List Integrations { get; set; } +} + +/// +/// MCP tool for listing available Aspire hosting integrations. +/// +internal sealed class ListIntegrationsTool(IPackagingService packagingService, CliExecutionContext executionContext) : CliMcpTool +{ + public override string Name => "list_integrations"; + + public override string Description => "List available Aspire hosting integrations. These are NuGet packages that can be added to an Aspire AppHost project to integrate with various services like databases, message brokers, and cloud services. Use 'aspire add ' to add an integration to your AppHost project. Use the 'get_integration_docs' tool to get detailed documentation for a specific integration. This tool does not require a running AppHost."; + + public override JsonElement GetInputSchema() + { + return JsonDocument.Parse(""" + { + "type": "object", + "properties": {}, + "additionalProperties": false, + "description": "This tool takes no input parameters. It returns a list of available Aspire hosting integrations with their short name, full package ID, and version." + } + """).RootElement; + } + + public override async ValueTask CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary? arguments, CancellationToken cancellationToken) + { + // This tool does not use the MCP client as it operates locally + _ = mcpClient; + _ = arguments; + + try + { + var allPackages = new List<(string FriendlyName, string PackageId, string Version, string Channel)>(); + + var packageChannels = await packagingService.GetChannelsAsync(cancellationToken); + + foreach (var channel in packageChannels) + { + var integrationPackages = await channel.GetIntegrationPackagesAsync(executionContext.WorkingDirectory, cancellationToken); + + foreach (var package in integrationPackages) + { + // Extract friendly name from package ID (e.g., "Aspire.Hosting.Redis" -> "Redis") + var friendlyName = GetFriendlyName(package.Id); + allPackages.Add((friendlyName, package.Id, package.Version, channel.Name)); + } + } + + // Group by package ID and take the latest version using semantic version comparison + // Parse version once and include it in the result to avoid redundant parsing + var packagesWithParsedVersions = allPackages + .Select(p => (p.FriendlyName, p.PackageId, p.Version, p.Channel, ParsedVersion: SemVersion.TryParse(p.Version, SemVersionStyles.Any, out var v) ? v : null)) + .Where(p => p.ParsedVersion is not null) + .ToList(); + + var distinctPackages = packagesWithParsedVersions + .GroupBy(p => p.PackageId) + .Select(g => g.OrderByDescending(p => p.ParsedVersion!, SemVersion.PrecedenceComparer).First()) + .OrderBy(p => p.FriendlyName) + .ToList(); + + var integrations = distinctPackages.Select(p => new Integration + { + Name = p.FriendlyName, + PackageId = p.PackageId, + Version = p.Version + }).ToList(); + + var response = new ListIntegrationsResponse + { + Integrations = integrations + }; + + var jsonContent = JsonSerializer.Serialize(response, JsonSourceGenerationContext.Default.ListIntegrationsResponse); + + return new CallToolResult + { + Content = [new TextContentBlock { Text = jsonContent }] + }; + } + catch (Exception ex) + { + return new CallToolResult + { + IsError = true, + Content = [new TextContentBlock { Text = $"Failed to list integrations: {ex.Message}" }] + }; + } + } + + private static string GetFriendlyName(string packageId) + { + // Handle CommunityToolkit packages + if (packageId.StartsWith("CommunityToolkit.Aspire.Hosting.", StringComparison.Ordinal)) + { + return packageId["CommunityToolkit.Aspire.Hosting.".Length..]; + } + + // Handle Aspire.Hosting packages + if (packageId.StartsWith("Aspire.Hosting.", StringComparison.Ordinal)) + { + return packageId["Aspire.Hosting.".Length..]; + } + + return packageId; + } +} diff --git a/tests/Aspire.Cli.Tests/Mcp/ListIntegrationsToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ListIntegrationsToolTests.cs new file mode 100644 index 00000000000..379d3b75993 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Mcp/ListIntegrationsToolTests.cs @@ -0,0 +1,103 @@ +// 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 Aspire.Cli.Mcp; + +namespace Aspire.Cli.Tests.Mcp; + +public class ListIntegrationsToolTests +{ + [Fact] + public void ListIntegrationsTool_HasCorrectName() + { + var tool = new ListIntegrationsTool(new MockPackagingService(), TestExecutionContextFactory.CreateTestContext()); + + Assert.Equal("list_integrations", tool.Name); + } + + [Fact] + public void ListIntegrationsTool_HasCorrectDescription() + { + var tool = new ListIntegrationsTool(new MockPackagingService(), TestExecutionContextFactory.CreateTestContext()); + + Assert.Contains("List available Aspire hosting integrations", tool.Description); + Assert.Contains("This tool does not require a running AppHost", tool.Description); + } + + [Fact] + public void ListIntegrationsTool_GetInputSchema_ReturnsValidSchema() + { + var tool = new ListIntegrationsTool(new MockPackagingService(), TestExecutionContextFactory.CreateTestContext()); + var schema = tool.GetInputSchema(); + + Assert.Equal(JsonValueKind.Object, schema.ValueKind); + Assert.True(schema.TryGetProperty("type", out var typeElement)); + Assert.Equal("object", typeElement.GetString()); + Assert.True(schema.TryGetProperty("properties", out var propsElement)); + Assert.Equal(JsonValueKind.Object, propsElement.ValueKind); + Assert.True(schema.TryGetProperty("description", out var descElement)); + Assert.Contains("This tool takes no input parameters", descElement.GetString()); + Assert.True(schema.TryGetProperty("additionalProperties", out var additionalPropsElement)); + Assert.False(additionalPropsElement.GetBoolean()); + } + + [Fact] + public async Task ListIntegrationsTool_CallToolAsync_ReturnsEmptyJsonArray_WhenNoPackagesFound() + { + var mockPackagingService = new MockPackagingService(); + var tool = new ListIntegrationsTool(mockPackagingService, TestExecutionContextFactory.CreateTestContext()); + + var result = await tool.CallToolAsync(null!, null, CancellationToken.None); + + Assert.True(result.IsError is null or false); + Assert.NotNull(result.Content); + Assert.Single(result.Content); + var textContent = result.Content[0] as ModelContextProtocol.Protocol.TextContentBlock; + Assert.NotNull(textContent); + + // Verify it's valid JSON with empty integrations array + using var json = JsonDocument.Parse(textContent.Text); + Assert.True(json.RootElement.TryGetProperty("integrations", out var integrations)); + Assert.Equal(JsonValueKind.Array, integrations.ValueKind); + Assert.Equal(0, integrations.GetArrayLength()); + } + + [Fact] + public async Task ListIntegrationsTool_CallToolAsync_ReturnsJsonWithPackages_WhenPackagesFound() + { + var mockPackagingService = new MockPackagingService(new[] + { + new Aspire.Shared.NuGetPackageCli { Id = "Aspire.Hosting.Redis", Version = "9.0.0" }, + new Aspire.Shared.NuGetPackageCli { Id = "Aspire.Hosting.PostgreSQL", Version = "9.0.0" } + }); + var tool = new ListIntegrationsTool(mockPackagingService, TestExecutionContextFactory.CreateTestContext()); + + var result = await tool.CallToolAsync(null!, null, CancellationToken.None); + + Assert.True(result.IsError is null or false); + Assert.NotNull(result.Content); + Assert.Single(result.Content); + var textContent = result.Content[0] as ModelContextProtocol.Protocol.TextContentBlock; + Assert.NotNull(textContent); + + // Verify it's valid JSON with proper structure + using var json = JsonDocument.Parse(textContent.Text); + Assert.True(json.RootElement.TryGetProperty("integrations", out var integrations)); + Assert.Equal(JsonValueKind.Array, integrations.ValueKind); + Assert.Equal(2, integrations.GetArrayLength()); + + // Verify the first integration has the expected properties + var firstIntegration = integrations[0]; + Assert.True(firstIntegration.TryGetProperty("name", out _)); + Assert.True(firstIntegration.TryGetProperty("packageId", out _)); + Assert.True(firstIntegration.TryGetProperty("version", out _)); + + // Check that the packages are included (order may vary) + var packageIds = integrations.EnumerateArray() + .Select(e => e.GetProperty("packageId").GetString()) + .ToList(); + Assert.Contains("Aspire.Hosting.Redis", packageIds); + Assert.Contains("Aspire.Hosting.PostgreSQL", packageIds); + } +} diff --git a/tests/Aspire.Cli.Tests/Mcp/MockPackagingService.cs b/tests/Aspire.Cli.Tests/Mcp/MockPackagingService.cs new file mode 100644 index 00000000000..83587f7d2f8 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Mcp/MockPackagingService.cs @@ -0,0 +1,59 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.NuGet; +using Aspire.Cli.Packaging; +using Aspire.Shared; + +namespace Aspire.Cli.Tests.Mcp; + +internal sealed class MockPackagingService : IPackagingService +{ + private readonly NuGetPackageCli[] _packages; + + public MockPackagingService(NuGetPackageCli[]? packages = null) + { + _packages = packages ?? []; + } + + public Task> GetChannelsAsync(CancellationToken cancellationToken = default) + { + var nugetCache = new MockNuGetPackageCache(_packages); + var channels = new[] { PackageChannel.CreateImplicitChannel(nugetCache) }; + return Task.FromResult>(channels); + } +} + +internal sealed class MockNuGetPackageCache : INuGetPackageCache +{ + private readonly NuGetPackageCli[] _packages; + + public MockNuGetPackageCache(NuGetPackageCli[]? packages = null) + { + _packages = packages ?? []; + } + + public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) + => Task.FromResult>([]); + + public Task> GetIntegrationPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) + => Task.FromResult>(_packages); + + public Task> GetCliPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) + => Task.FromResult>([]); + + public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) + => Task.FromResult>([]); +} + +internal static class TestExecutionContextFactory +{ + public static CliExecutionContext CreateTestContext() + { + return new CliExecutionContext( + new DirectoryInfo(Path.GetTempPath()), + new DirectoryInfo(Path.Combine(Path.GetTempPath(), "hives")), + new DirectoryInfo(Path.Combine(Path.GetTempPath(), "cache")), + new DirectoryInfo(Path.Combine(Path.GetTempPath(), "sdks"))); + } +} diff --git a/tests/Aspire.Cli.Tests/TestServices/TestAuxiliaryBackchannelMonitor.cs b/tests/Aspire.Cli.Tests/TestServices/TestAuxiliaryBackchannelMonitor.cs index 1dd68f4479c..9abbca56d9a 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestAuxiliaryBackchannelMonitor.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestAuxiliaryBackchannelMonitor.cs @@ -13,6 +13,69 @@ internal sealed class TestAuxiliaryBackchannelMonitor : IAuxiliaryBackchannelMon public string? SelectedAppHostPath { get; set; } + public AppHostConnection? SelectedConnection + { + get + { + var connections = _connections.Values.ToList(); + + if (connections.Count == 0) + { + return null; + } + + // Check if a specific AppHost was selected + if (!string.IsNullOrEmpty(SelectedAppHostPath)) + { + var selectedConnection = connections.FirstOrDefault(c => + c.AppHostInfo?.AppHostPath != null && + string.Equals(Path.GetFullPath(c.AppHostInfo.AppHostPath), Path.GetFullPath(SelectedAppHostPath), StringComparison.OrdinalIgnoreCase)); + + if (selectedConnection != null) + { + return selectedConnection; + } + + // Clear the selection since the AppHost is no longer available + SelectedAppHostPath = null; + } + + // Look for in-scope connections + var inScopeConnections = connections.Where(c => c.IsInScope).ToList(); + + if (inScopeConnections.Count == 1) + { + return inScopeConnections[0]; + } + + // Fall back to the first available connection + return connections.FirstOrDefault(); + } + } + + public IReadOnlyList GetConnectionsForWorkingDirectory(DirectoryInfo workingDirectory) + { + return _connections.Values + .Where(c => IsAppHostInScopeOfDirectory(c.AppHostInfo?.AppHostPath, workingDirectory.FullName)) + .ToList(); + } + + private static bool IsAppHostInScopeOfDirectory(string? appHostPath, string workingDirectory) + { + if (string.IsNullOrEmpty(appHostPath)) + { + return false; + } + + // Normalize the paths for comparison + var normalizedWorkingDirectory = Path.GetFullPath(workingDirectory); + var normalizedAppHostPath = Path.GetFullPath(appHostPath); + + // Check if the AppHost path is within the working directory + var relativePath = Path.GetRelativePath(normalizedWorkingDirectory, normalizedAppHostPath); + return !relativePath.StartsWith("..", StringComparison.Ordinal) && !Path.IsPathRooted(relativePath); + } + public void AddConnection(string hash, AppHostConnection connection) { _connections[hash] = connection;