File: Mcp\Tools\SelectAppHostTool.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 Aspire.Cli.Backchannel;
using Aspire.Hosting.Utils;
using ModelContextProtocol.Protocol;
 
namespace Aspire.Cli.Mcp.Tools;
 
/// <summary>
/// MCP tool for selecting which AppHost to use when multiple are running.
/// </summary>
internal sealed class SelectAppHostTool(IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor, CliExecutionContext executionContext) : CliMcpTool
{
    public override string Name => KnownMcpTools.SelectAppHost;
 
    public override string Description => "Selects which AppHost to use when multiple AppHosts are running. The path can be a fully qualified path or a workspace root relative path.";
 
    public override ToolAnnotations Annotations => new()
    {
        ReadOnlyHint = false,
        DestructiveHint = false
    };
 
    public override JsonElement GetInputSchema()
    {
        return JsonDocument.Parse("""
            {
              "type": "object",
              "properties": {
                "appHostPath": {
                  "type": "string",
                  "description": "The fully qualified or workspace root relative path to the AppHost project."
                }
              },
              "required": ["appHostPath"]
            }
            """).RootElement;
    }
 
    public override ValueTask<CallToolResult> CallToolAsync(CallToolContext context, CancellationToken cancellationToken)
    {
        var arguments = context.Arguments;
 
        if (arguments == null || !arguments.TryGetValue("appHostPath", out var appHostPathElement))
        {
            return ValueTask.FromResult(new CallToolResult
            {
                IsError = true,
                Content = [new TextContentBlock { Text = "The 'appHostPath' argument is required." }]
            });
        }
 
        var appHostPath = appHostPathElement.GetString();
        if (string.IsNullOrWhiteSpace(appHostPath))
        {
            return ValueTask.FromResult(new CallToolResult
            {
                IsError = true,
                Content = [new TextContentBlock { Text = "The 'appHostPath' argument cannot be empty." }]
            });
        }
 
        // Preserve the caller's spelling for diagnostics while using a canonical identity for matching.
        var displayPath = Path.GetFullPath(
            Path.IsPathRooted(appHostPath)
                ? appHostPath
                : Path.Combine(executionContext.WorkingDirectory.FullName, appHostPath));
        var canonicalPath = PathNormalizer.ResolveToFilesystemPath(displayPath);
 
        // Check if there's a running AppHost with this path
        IAppHostAuxiliaryBackchannel? matchingConnection;
        try
        {
            matchingConnection = AppHostConnectionHelper.FindConnectionByAppHostPath(
                auxiliaryBackchannelMonitor.Connections,
                canonicalPath);
        }
        catch (InvalidOperationException)
        {
            return ValueTask.FromResult(new CallToolResult
            {
                IsError = true,
                Content = [new TextContentBlock { Text = "Multiple running AppHost instances match that path. Stop the extra instance and retry." }]
            });
        }
 
        if (matchingConnection == null)
        {
            // The requested and available paths are local machine details. Keep the model-facing
            // error useful but path-free even when the caller supplied an absolute path.
            var hasAvailableAppHosts = auxiliaryBackchannelMonitor.Connections
                .Any(static connection => connection.AppHostInfo?.AppHostPath is not null);
            var message = hasAvailableAppHosts
                ? "No running AppHost matched 'appHostPath'. Other AppHosts are currently running."
                : "No running AppHost matched 'appHostPath'. No AppHosts are currently running.";
 
            return ValueTask.FromResult(new CallToolResult
            {
                IsError = true,
                Content = [new TextContentBlock { Text = message }]
            });
        }
 
        // Pin the connection's physical identity rather than the caller's spelling. A selected
        // symlink can be retargeted after this call, but it must not redirect later MCP operations
        // to a different running AppHost.
        auxiliaryBackchannelMonitor.SelectedAppHostPath =
            PathNormalizer.ResolveToFilesystemPath(matchingConnection.AppHostInfo!.AppHostPath);
 
        return ValueTask.FromResult(new CallToolResult
        {
            Content = [new TextContentBlock { Text = $"Selected AppHost: {displayPath}" }]
        });
    }
}