Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions src/Aspire.Cli/Backchannel/AuxiliaryBackchannelMonitor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,75 @@ internal sealed class AuxiliaryBackchannelMonitor(
/// </summary>
public string? SelectedAppHostPath { get; set; }

/// <summary>
/// Gets the currently selected AppHost connection based on the selection logic.
/// </summary>
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();
}
}

/// <summary>
/// Gets all connections that are within the scope of the specified working directory.
/// </summary>
public IReadOnlyList<AppHostConnection> 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
Expand Down
13 changes: 13 additions & 0 deletions src/Aspire.Cli/Backchannel/IAuxiliaryBackchannelMonitor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </summary>
string? SelectedAppHostPath { get; set; }

/// <summary>
/// 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.
/// </summary>
AppHostConnection? SelectedConnection { get; }

/// <summary>
/// Gets all connections that are within the scope of the specified working directory.
/// </summary>
/// <param name="workingDirectory">The working directory to check against.</param>
/// <returns>A list of in-scope connections.</returns>
IReadOnlyList<AppHostConnection> GetConnectionsForWorkingDirectory(DirectoryInfo workingDirectory);
}
6 changes: 4 additions & 2 deletions src/Aspire.Cli/Commands/McpCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -25,12 +26,13 @@ public McpCommand(
ILoggerFactory loggerFactory,
ILogger<McpStartCommand> 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);
Expand Down
11 changes: 7 additions & 4 deletions src/Aspire.Cli/Commands/McpStartCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -25,7 +26,7 @@ internal sealed class McpStartCommand : BaseCommand
private readonly ILoggerFactory _loggerFactory;
private readonly ILogger<McpStartCommand> _logger;

public McpStartCommand(IInteractionService interactionService, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor, ILoggerFactory loggerFactory, ILogger<McpStartCommand> logger)
public McpStartCommand(IInteractionService interactionService, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor, ILoggerFactory loggerFactory, ILogger<McpStartCommand> logger, IPackagingService packagingService)
: base("start", McpCommandStrings.StartCommand_Description, features, updateNotifier, executionContext, interactionService)
{
_auxiliaryBackchannelMonitor = auxiliaryBackchannelMonitor;
Expand All @@ -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()
};
}

Expand Down Expand Up @@ -100,8 +103,8 @@ private async ValueTask<CallToolResult> HandleCallToolAsync(RequestContext<CallT

if (_tools.TryGetValue(toolName, out var tool))
{
// Handle select_apphost and list_apphosts tools specially - they don't need an MCP connection
if (toolName is "select_apphost" or "list_apphosts")
// Handle tools that don't need an MCP connection to the AppHost
if (toolName is "select_apphost" or "list_apphosts" or "list_integrations" or "get_integration_docs")
{
return await tool.CallToolAsync(null!, request.Params?.Arguments, cancellationToken);
}
Expand Down
3 changes: 3 additions & 0 deletions src/Aspire.Cli/JsonSourceGenerationContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@

using System.Text.Json.Serialization;
using System.Text.Json.Nodes;
using Aspire.Cli.Mcp;

namespace Aspire.Cli;

[JsonSourceGenerationOptions(WriteIndented = true)]
[JsonSerializable(typeof(CliSettings))]
[JsonSerializable(typeof(JsonObject))]
[JsonSerializable(typeof(ListIntegrationsResponse))]
[JsonSerializable(typeof(Integration))]
internal partial class JsonSourceGenerationContext : JsonSerializerContext
{
}
106 changes: 106 additions & 0 deletions src/Aspire.Cli/Mcp/GetIntegrationDocsTool.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// 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 ModelContextProtocol.Protocol;

namespace Aspire.Cli.Mcp;

/// <summary>
/// MCP tool for getting documentation for a specific Aspire hosting integration.
/// </summary>
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<CallToolResult> CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary<string, JsonElement>? 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 }]
});
}
}
Comment on lines +12 to +106
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing test coverage for GetIntegrationDocsTool. While ListIntegrationsTool has comprehensive tests, the GetIntegrationDocsTool lacks any test coverage.

Consider adding tests similar to ListIntegrationsToolTests.cs that verify:

  • Correct tool name and description
  • Valid input schema
  • Error handling when arguments are null or missing
  • Error handling when packageId or packageVersion are empty
  • Successful response with valid parameters
  • Correct URL formatting in the response

Copilot uses AI. Check for mistakes.
Loading
Loading