File: Mcp\Tools\ExecuteResourceCommandTool.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.Backchannel;
using Aspire.Cli.Commands;
using Microsoft.Extensions.Logging;
using ModelContextProtocol;
using ModelContextProtocol.Protocol;
 
namespace Aspire.Cli.Mcp.Tools;
 
/// <summary>
/// MCP tool for executing commands on resources.
/// Executes commands directly via the AppHost backchannel.
/// </summary>
internal sealed class ExecuteResourceCommandTool(
    IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor,
    ILogger<ExecuteResourceCommandTool> logger) : CliMcpTool
{
    public override string Name => KnownMcpTools.ExecuteResourceCommand;
 
    public override string Description => "Executes a command on a resource. If a resource needs to be restarted and is currently stopped, use the start command instead.";
 
    public override JsonElement GetInputSchema()
    {
        // MCP input schema JSON accepts optional nested command arguments:
        // { "resourceName": "web", "commandName": "click", "arguments": { "selector": "#submit", "urgent": true, "optional": null } }
        using var document = JsonDocument.Parse("""
            {
              "type": "object",
              "properties": {
                "resourceName": {
                  "type": "string",
                  "description": "The resource name"
                },
                "commandName": {
                  "type": "string",
                  "description": "The command name"
                },
                "arguments": {
                  "type": "object",
                  "description": "Optional invocation arguments to pass to the resource command",
                  "additionalProperties": {
                    "type": ["string", "number", "boolean", "null"],
                    "description": "Argument values must be strings, numbers, booleans, or null."
                  }
                }
              },
              "required": ["resourceName", "commandName"]
            }
            """);
        return document.RootElement.Clone();
    }
 
    public override async ValueTask<CallToolResult> CallToolAsync(CallToolContext context, CancellationToken cancellationToken)
    {
        var toolArguments = context.Arguments;
        if (toolArguments is null ||
            !toolArguments.TryGetValue("resourceName", out var resourceNameElement) ||
            !toolArguments.TryGetValue("commandName", out var commandNameElement))
        {
            throw new McpProtocolException("Missing required arguments 'resourceName' and 'commandName'.", McpErrorCode.InvalidParams);
        }
 
        var resourceName = resourceNameElement.GetString();
        var commandName = commandNameElement.GetString();
 
        if (string.IsNullOrEmpty(resourceName) || string.IsNullOrEmpty(commandName))
        {
            throw new McpProtocolException("Arguments 'resourceName' and 'commandName' cannot be empty.", McpErrorCode.InvalidParams);
        }
 
        JsonNode? commandArguments = null;
        if (toolArguments.TryGetValue("arguments", out var commandArgumentsElement))
        {
            if (commandArgumentsElement.ValueKind != JsonValueKind.Object)
            {
                throw new McpProtocolException("Argument 'arguments' must be a JSON object.", McpErrorCode.InvalidParams);
            }
 
            commandArguments = CreateCommandArguments(commandArgumentsElement);
        }
 
        var connection = await AppHostConnectionHelper.GetSelectedConnectionAsync(auxiliaryBackchannelMonitor, logger, cancellationToken).ConfigureAwait(false);
        if (connection is null)
        {
            logger.LogWarning("No Aspire AppHost is currently running");
            throw new McpProtocolException(McpErrorMessages.NoAppHostRunning, McpErrorCode.InternalError);
        }
 
        // Check if the resource is excluded from MCP before executing commands.
        var excludedResult = await McpToolHelpers.CheckResourceExcludedAsync(connection, resourceName, cancellationToken).ConfigureAwait(false);
        if (excludedResult is not null)
        {
            return excludedResult;
        }
 
        try
        {
            logger.LogDebug("Executing command '{CommandName}' on resource '{ResourceName}' via backchannel", commandName, resourceName);
 
            var response = await connection.ExecuteResourceCommandAsync(
                resourceName,
                commandName,
                new ExecuteResourceCommandOptions
                {
                    Arguments = commandArguments,
                    NonInteractive = true
                },
                cancellationToken).ConfigureAwait(false);
 
            if (response.Success)
            {
                var content = new List<TextContentBlock>
                {
                    new() { Text = $"Command '{commandName}' executed successfully on resource '{resourceName}'." }
                };
 
                if (response.Value is not null)
                {
                    content.Add(new TextContentBlock { Text = response.Value.Value });
                }
 
                return new CallToolResult
                {
                    Content = [.. content]
                };
            }
            else if (response.Canceled)
            {
                throw new McpProtocolException($"Command '{commandName}' was cancelled.", McpErrorCode.InternalError);
            }
            else
            {
#pragma warning disable CS0618 // Type or member is obsolete
                var message = (response.Message ?? response.ErrorMessage) is { Length: > 0 } errorMsg ? errorMsg : "Unknown error. See logs for details.";
#pragma warning restore CS0618 // Type or member is obsolete
                if (response.ValidationErrors is { Length: > 0 })
                {
                    message = $"{message}{Environment.NewLine}{string.Join(Environment.NewLine, response.ValidationErrors.Select(error => $"{ResourceCommandHelper.FormatArgumentNameForDisplay(error.ArgumentName)}: {error.ErrorMessage}"))}";
                }
 
                var content = new List<TextContentBlock>
                {
                    new() { Text = $"Command '{commandName}' failed for resource '{resourceName}': {message}" }
                };
 
                if (response.Value is not null)
                {
                    content.Add(new TextContentBlock { Text = response.Value.Value });
                }
 
                return new CallToolResult
                {
                    IsError = true,
                    Content = [.. content]
                };
            }
        }
        catch (McpProtocolException)
        {
            throw;
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Error executing command '{CommandName}' on resource '{ResourceName}'", commandName, resourceName);
            throw new McpProtocolException($"Error executing command '{commandName}' for resource '{resourceName}': {ex.Message}", McpErrorCode.InternalError);
        }
    }
 
    private static JsonObject CreateCommandArguments(JsonElement commandArgumentsElement)
    {
        var arguments = new JsonObject();
        foreach (var property in commandArgumentsElement.EnumerateObject())
        {
            arguments[property.Name] = property.Value.ValueKind switch
            {
                JsonValueKind.String => JsonValue.Create(property.Value.GetString()),
                JsonValueKind.Number => JsonValue.Create(property.Value.GetRawText()),
                JsonValueKind.True => JsonValue.Create("true"),
                JsonValueKind.False => JsonValue.Create("false"),
                JsonValueKind.Null => null,
                _ => throw new McpProtocolException($"Argument 'arguments.{property.Name}' must be a string, number, boolean, or null.", McpErrorCode.InvalidParams)
            };
        }
 
        return arguments;
    }
}