diff --git a/playground/PostgresEndToEnd/PostgresEndToEnd.AppHost/Program.cs b/playground/PostgresEndToEnd/PostgresEndToEnd.AppHost/Program.cs index 95b8a257d74..ef8f4bc25cd 100644 --- a/playground/PostgresEndToEnd/PostgresEndToEnd.AppHost/Program.cs +++ b/playground/PostgresEndToEnd/PostgresEndToEnd.AppHost/Program.cs @@ -3,9 +3,10 @@ var builder = DistributedApplication.CreateBuilder(args); -var db1 = builder.AddAzurePostgresFlexibleServer("pg") - .RunAsContainer() - .AddDatabase("db1"); +var pg = builder.AddPostgres("pg") + .WithPostgresMcp(); + +var db1 = pg.AddDatabase("db1"); builder.AddProject("api") .WithExternalHttpEndpoints() diff --git a/playground/Redis/Redis.AppHost/Program.cs b/playground/Redis/Redis.AppHost/Program.cs index caa7d08b6fb..2fcda3e3334 100644 --- a/playground/Redis/Redis.AppHost/Program.cs +++ b/playground/Redis/Redis.AppHost/Program.cs @@ -3,7 +3,8 @@ var redis = builder.AddRedis("redis"); redis.WithDataVolume() .WithRedisCommander(c => c.WithHostPort(33803).WithParentRelationship(redis)) - .WithRedisInsight(c => c.WithHostPort(41567).WithParentRelationship(redis)); + .WithRedisInsight(c => c.WithHostPort(41567).WithParentRelationship(redis)) + .WithRedisMcp(); var garnet = builder.AddGarnet("garnet") .WithDataVolume(); diff --git a/src/Aspire.Cli/Backchannel/AuxiliaryBackchannelMonitor.cs b/src/Aspire.Cli/Backchannel/AuxiliaryBackchannelMonitor.cs index 527be6ac2f1..9eddb9f306b 100644 --- a/src/Aspire.Cli/Backchannel/AuxiliaryBackchannelMonitor.cs +++ b/src/Aspire.Cli/Backchannel/AuxiliaryBackchannelMonitor.cs @@ -35,7 +35,24 @@ internal sealed class AuxiliaryBackchannelMonitor( /// /// Gets or sets the path to the selected AppHost. When set, this AppHost will be used for MCP operations. /// - public string? SelectedAppHostPath { get; set; } + public string? SelectedAppHostPath + { + get => _selectedAppHostPath; + set + { + if (_selectedAppHostPath != value) + { + _selectedAppHostPath = value; + SelectedAppHostChanged?.Invoke(); + } + } + } + private string? _selectedAppHostPath; + + /// + /// Event raised when the selected AppHost changes. + /// + public event Action? SelectedAppHostChanged; /// /// Gets the currently selected AppHost connection based on the selection logic. diff --git a/src/Aspire.Cli/Backchannel/IAuxiliaryBackchannelMonitor.cs b/src/Aspire.Cli/Backchannel/IAuxiliaryBackchannelMonitor.cs index 22f86cdfd2c..c60244509a5 100644 --- a/src/Aspire.Cli/Backchannel/IAuxiliaryBackchannelMonitor.cs +++ b/src/Aspire.Cli/Backchannel/IAuxiliaryBackchannelMonitor.cs @@ -30,4 +30,9 @@ internal interface IAuxiliaryBackchannelMonitor /// The working directory to check against. /// A list of in-scope connections. IReadOnlyList GetConnectionsForWorkingDirectory(DirectoryInfo workingDirectory); + + /// + /// Event raised when the selected AppHost changes. + /// + event Action? SelectedAppHostChanged; } diff --git a/src/Aspire.Cli/Commands/McpStartCommand.cs b/src/Aspire.Cli/Commands/McpStartCommand.cs index 4e462700586..0f172ed9808 100644 --- a/src/Aspire.Cli/Commands/McpStartCommand.cs +++ b/src/Aspire.Cli/Commands/McpStartCommand.cs @@ -3,6 +3,7 @@ using System.CommandLine; using System.Globalization; +using System.Text.Json; using Aspire.Cli.Backchannel; using Aspire.Cli.Configuration; using Aspire.Cli.Interaction; @@ -21,12 +22,18 @@ namespace Aspire.Cli.Commands; internal sealed class McpStartCommand : BaseCommand { - private readonly Dictionary _tools; + private readonly Dictionary _cliTools; private readonly IAuxiliaryBackchannelMonitor _auxiliaryBackchannelMonitor; private readonly CliExecutionContext _executionContext; private readonly ILoggerFactory _loggerFactory; private readonly ILogger _logger; + private McpServer? _mcpServer; + + // Persistent MCP client for listening to tool list changes + private McpClient? _notificationClient; + private IAsyncDisposable? _toolListChangedHandler; + 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) { @@ -34,14 +41,9 @@ public McpStartCommand(IInteractionService interactionService, IFeatures feature _executionContext = executionContext; _loggerFactory = loggerFactory; _logger = logger; - _tools = new Dictionary - { - ["list_resources"] = new ListResourcesTool(), - ["list_console_logs"] = new ListConsoleLogsTool(), - ["execute_resource_command"] = new ExecuteResourceCommandTool(), - ["list_structured_logs"] = new ListStructuredLogsTool(), - ["list_traces"] = new ListTracesTool(), - ["list_trace_structured_logs"] = new ListTraceStructuredLogsTool(), + // Only CLI-specific tools are hardcoded; AppHost tools are fetched dynamically + _cliTools = new Dictionary + { ["select_apphost"] = new SelectAppHostTool(auxiliaryBackchannelMonitor, executionContext), ["list_apphosts"] = new ListAppHostsTool(auxiliaryBackchannelMonitor, executionContext), ["list_integrations"] = new ListIntegrationsTool(packagingService, executionContext, auxiliaryBackchannelMonitor), @@ -63,137 +65,386 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell Version = VersionHelper.GetDefaultTemplateVersion(), Icons = icons }, + Capabilities = new ServerCapabilities + { + Tools = new ToolsCapability + { + // Indicate that this server supports tools/list_changed notifications + ListChanged = true + } + }, Handlers = new McpServerHandlers() { ListToolsHandler = HandleListToolsAsync, CallToolHandler = HandleCallToolAsync - }, + }, }; await using var server = McpServer.Create(new StdioServerTransport("aspire-mcp-server"), options); - await server.RunAsync(cancellationToken); + _mcpServer = server; + + // Subscribe to AppHost selection changes to notify clients + _auxiliaryBackchannelMonitor.SelectedAppHostChanged += OnSelectedAppHostChanged; + + try + { + await server.RunAsync(cancellationToken); + } + finally + { + _auxiliaryBackchannelMonitor.SelectedAppHostChanged -= OnSelectedAppHostChanged; + } + + // Dispose notification resources + if (_toolListChangedHandler is not null) + { + await _toolListChangedHandler.DisposeAsync(); + } + if (_notificationClient is not null) + { + await _notificationClient.DisposeAsync(); + } return ExitCodeConstants.Success; } - private ValueTask HandleListToolsAsync(RequestContext request, CancellationToken cancellationToken) + /// + /// Called when the selected AppHost changes. Invalidates the cache and notifies clients. + /// + private void OnSelectedAppHostChanged() { - // Parameters required by delegate signature - _ = request; - _ = cancellationToken; + _logger.LogDebug("Selected AppHost changed, notifying clients"); + + // Notify clients that the tool list has changed + if (_mcpServer is not null) + { + _ = Task.Run(async () => + { + try + { + await _mcpServer.SendMessageAsync( + new JsonRpcNotification + { + Method = NotificationMethods.ToolListChangedNotification + }, + CancellationToken.None); + _logger.LogInformation("Sent tool list changed notification after AppHost selection change"); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to send tool list changed notification"); + } + }); + } + } + private async ValueTask HandleListToolsAsync(RequestContext request, CancellationToken cancellationToken) + { _logger.LogDebug("MCP ListTools request received"); - var tools = _tools.Values.Select(tool => new Tool + var tools = new List(); + + // Always add CLI-specific tools + foreach (var cliTool in _cliTools.Values) + { + tools.Add(new Tool + { + Name = cliTool.Name, + Description = cliTool.Description, + InputSchema = cliTool.GetInputSchema() + }); + } + + // Try to get tools from the selected AppHost + var appHostTools = await GetAppHostToolsAsync(cancellationToken); + if (appHostTools is not null) { - Name = tool.Name, - Description = tool.Description, - InputSchema = tool.GetInputSchema() - }).ToArray(); + tools.AddRange(appHostTools); + } - _logger.LogDebug("Returning {ToolCount} tools: {ToolNames}", tools.Length, string.Join(", ", tools.Select(t => t.Name))); + _logger.LogDebug("Returning {ToolCount} tools: {ToolNames}", tools.Count, string.Join(", ", tools.Select(t => t.Name))); - return ValueTask.FromResult(new ListToolsResult + return new ListToolsResult { Tools = tools - }); + }; } - private async ValueTask HandleCallToolAsync(RequestContext request, CancellationToken cancellationToken) + /// + /// Gets tools from the currently selected AppHost, using cache when possible. + /// + private async ValueTask?> GetAppHostToolsAsync(CancellationToken cancellationToken) { - var toolName = request.Params?.Name ?? string.Empty; + var connection = TryGetSelectedConnection(); + if (connection is null || connection.McpInfo is null) + { + return null; + } - _logger.LogDebug("MCP CallTool request received for tool: {ToolName}", toolName); + var currentAppHostPath = connection.AppHostInfo?.AppHostPath; - if (_tools.TryGetValue(toolName, out var tool)) + // Fetch tools from the AppHost's MCP server + try { - // 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); - } + _logger.LogDebug("Fetching tools from AppHost: {AppHostPath}", currentAppHostPath); - // Get the appropriate connection using the new selection logic - var connection = GetSelectedConnection(); - if (connection == null) - { - _logger.LogWarning("No Aspire AppHost is currently running"); - throw new McpProtocolException( - "No Aspire AppHost is currently running. " + - "To use Aspire MCP tools, you must first start an Aspire application by running 'aspire run' in your AppHost project directory. " + - "Once the application is running, the MCP tools will be able to connect to the dashboard and execute commands.", - McpErrorCode.InternalError); - } + await using var mcpClient = await CreateMcpClientAsync(connection, cancellationToken); + var clientTools = await mcpClient.ListToolsAsync(cancellationToken: cancellationToken); + + // Convert McpClientTool to Tool + var tools = clientTools.Select(t => t.ProtocolTool).ToList(); + + // Subscribe to tool list changes from the AppHost + await SubscribeToToolListChangesAsync(connection, cancellationToken); + + _logger.LogDebug("Fetched {ToolCount} tools from AppHost: {ToolNames}", tools.Count, string.Join(", ", tools.Select(t => t.Name))); + return tools; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to fetch tools from AppHost: {AppHostPath}", currentAppHostPath); + return null; + } + } + + /// + /// Subscribes to tool list changes from the AppHost and forwards them to clients. + /// + private async Task SubscribeToToolListChangesAsync(AppHostConnection connection, CancellationToken cancellationToken) + { + // Dispose previous resources if any + if (_toolListChangedHandler is not null) + { + await _toolListChangedHandler.DisposeAsync(); + _toolListChangedHandler = null; + } + if (_notificationClient is not null) + { + await _notificationClient.DisposeAsync(); + _notificationClient = null; + } + + if (connection.McpInfo is null) + { + return; + } + + try + { + // Create a persistent MCP client for receiving notifications + _notificationClient = await CreateMcpClientAsync(connection, cancellationToken); - if (connection.McpInfo == null) + // Register handler for tool list changes + _toolListChangedHandler = _notificationClient.RegisterNotificationHandler( + NotificationMethods.ToolListChangedNotification, + async (notification, ct) => + { + _logger.LogDebug("Received tool list changed notification from AppHost"); + + // Forward the notification to our clients + if (_mcpServer is not null) + { + try + { + await _mcpServer.SendMessageAsync( + new JsonRpcNotification + { + Method = NotificationMethods.ToolListChangedNotification + }, + ct); + _logger.LogDebug("Forwarded tool list changed notification to clients"); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to forward tool list changed notification"); + } + } + }); + + _logger.LogDebug("Subscribed to tool list changes from AppHost: {AppHostPath}", connection.AppHostInfo?.AppHostPath); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to subscribe to tool list changes from AppHost"); + } + } + + /// + /// Creates an MCP client to communicate with the AppHost's dashboard MCP server. + /// + private async Task CreateMcpClientAsync(AppHostConnection connection, CancellationToken cancellationToken) + { + var transportOptions = new HttpClientTransportOptions + { + Endpoint = new Uri(connection.McpInfo!.EndpointUrl), + AdditionalHeaders = new Dictionary { - _logger.LogWarning("Dashboard is not available in the running AppHost"); - throw new McpProtocolException( - "The Aspire Dashboard is not available in the running AppHost. " + - "The dashboard must be enabled to use MCP tools. " + - "Ensure your AppHost is configured with the dashboard enabled (this is the default configuration).", - McpErrorCode.InternalError); + ["x-mcp-api-key"] = connection.McpInfo.ApiToken } + }; + + var httpClient = new HttpClient(); + var transport = new HttpClientTransport(transportOptions, httpClient, _loggerFactory, ownsHttpClient: true); + + return await McpClient.CreateAsync(transport, cancellationToken: cancellationToken); + } + + private async ValueTask HandleCallToolAsync(RequestContext request, CancellationToken cancellationToken) + { + var toolName = request.Params?.Name ?? string.Empty; + + _logger.LogInformation("MCP CallTool request received for tool: {ToolName}", toolName); + + // Handle CLI-specific tools - these don't need an MCP connection to the AppHost + if (_cliTools.TryGetValue(toolName, out var cliTool)) + { + var result = await cliTool.CallToolAsync(null!, request.Params?.Arguments, cancellationToken); - _logger.LogInformation( - "Connecting to dashboard MCP server. " + - "Dashboard URL: {EndpointUrl}, " + - "AppHost Path: {AppHostPath}, " + - "AppHost PID: {AppHostPid}, " + - "CLI PID: {CliPid}", - connection.McpInfo.EndpointUrl, - connection.AppHostInfo?.AppHostPath ?? "N/A", - connection.AppHostInfo?.ProcessId.ToString(CultureInfo.InvariantCulture) ?? "N/A", - connection.AppHostInfo?.CliProcessId?.ToString(CultureInfo.InvariantCulture) ?? "N/A"); - - // Create HTTP transport to the dashboard's MCP server - var transportOptions = new HttpClientTransportOptions + // After select_apphost, send a tool list changed notification so clients refresh tools + if (toolName == "select_apphost" && result.IsError == false && _mcpServer is { } mcpServer) { - Endpoint = new Uri(connection.McpInfo.EndpointUrl), - AdditionalHeaders = new Dictionary + try + { + await mcpServer.SendMessageAsync( + new JsonRpcNotification + { + Method = NotificationMethods.ToolListChangedNotification + }, + cancellationToken); + } + catch (Exception ex) { - ["x-mcp-api-key"] = connection.McpInfo.ApiToken + _logger.LogWarning(ex, "Failed to send tools/list_changed notification after select_apphost"); } - }; + } + + return result; + } - using var httpClient = new HttpClient(); + // For all other tools, forward to the AppHost's MCP server + var connection = GetSelectedConnection(); + if (connection is null) + { + _logger.LogWarning("No Aspire AppHost is currently running"); + throw new McpProtocolException( + "No Aspire AppHost is currently running. " + + "To use Aspire MCP tools, you must first start an Aspire application by running 'aspire run' in your AppHost project directory. " + + "Once the application is running, the MCP tools will be able to connect to the dashboard and execute commands.", + McpErrorCode.InternalError); + } - await using var transport = new HttpClientTransport(transportOptions, httpClient, _loggerFactory, ownsHttpClient: true); + if (connection.McpInfo is null) + { + _logger.LogWarning("Dashboard is not available in the running AppHost"); + throw new McpProtocolException( + "The Aspire Dashboard is not available in the running AppHost. " + + "The dashboard must be enabled to use MCP tools. " + + "Ensure your AppHost is configured with the dashboard enabled (this is the default configuration).", + McpErrorCode.InternalError); + } - // Create MCP client to communicate with the dashboard - await using var mcpClient = await McpClient.CreateAsync(transport, cancellationToken: cancellationToken); + _logger.LogInformation( + "Sending tool command to dashboard MCP server: {ToolName} " + + "Dashboard URL: {EndpointUrl}, " + + "AppHost Path: {AppHostPath}, " + + "AppHost PID: {AppHostPid}, " + + "CLI PID: {CliPid}", + toolName, + connection.McpInfo.EndpointUrl, + connection.AppHostInfo?.AppHostPath ?? "N/A", + connection.AppHostInfo?.ProcessId.ToString(CultureInfo.InvariantCulture) ?? "N/A", + connection.AppHostInfo?.CliProcessId?.ToString(CultureInfo.InvariantCulture) ?? "N/A"); + + // Forward the tool call to the AppHost's MCP server + try + { + await using var mcpClient = await CreateMcpClientAsync(connection, cancellationToken); _logger.LogDebug("Calling tool {ToolName} on dashboard MCP server", toolName); - // Call the tool with the MCP client - try + // Convert JsonElement arguments to Dictionary + Dictionary? convertedArgs = null; + if (request.Params?.Arguments is not null) { - _logger.LogDebug("Invoking CallToolAsync for tool {ToolName} with arguments: {Arguments}", toolName, request.Params?.Arguments); - var result = await tool.CallToolAsync(mcpClient, request.Params?.Arguments, cancellationToken); - _logger.LogDebug("CallToolAsync for tool {ToolName} completed successfully", toolName); + convertedArgs = new Dictionary(); + foreach (var kvp in request.Params.Arguments) + { + convertedArgs[kvp.Key] = kvp.Value.ValueKind == JsonValueKind.Null ? null : kvp.Value; + } + } - _logger.LogDebug("Tool {ToolName} completed successfully", toolName); + var result = await mcpClient.CallToolAsync( + toolName, + convertedArgs, + serializerOptions: McpJsonUtilities.DefaultOptions, + cancellationToken: cancellationToken); - return result; - } - catch (Exception ex) + _logger.LogInformation("Tool {ToolName} completed successfully", toolName); + + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occurred while calling tool {ToolName}", toolName); + throw; + } + } + + /// + /// Tries to get the appropriate AppHost connection without throwing exceptions. + /// Returns null if no suitable connection is found. + /// + private AppHostConnection? TryGetSelectedConnection() + { + var connections = _auxiliaryBackchannelMonitor.Connections.Values.ToList(); + + if (connections.Count == 0) + { + return null; + } + + // Check if a specific AppHost was selected + var selectedPath = _auxiliaryBackchannelMonitor.SelectedAppHostPath; + if (!string.IsNullOrEmpty(selectedPath)) + { + var selectedConnection = connections.FirstOrDefault(c => + c.AppHostInfo?.AppHostPath is not null && + string.Equals(c.AppHostInfo.AppHostPath, selectedPath, StringComparison.OrdinalIgnoreCase)); + + if (selectedConnection is not null) { - _logger.LogError(ex, "Error occurred while calling tool {ToolName}", toolName); - throw; + return selectedConnection; } + + // Clear the selection since the AppHost is no longer available + _auxiliaryBackchannelMonitor.SelectedAppHostPath = null; + } + + // Get in-scope connections first + var inScopeConnections = connections.Where(c => c.IsInScope).ToList(); + + if (inScopeConnections.Count == 1) + { + return inScopeConnections[0]; + } + + // If no in-scope connections but exactly one total connection, use it + if (inScopeConnections.Count == 0 && connections.Count == 1) + { + return connections[0]; } - _logger.LogWarning("Unknown tool requested: {ToolName}", toolName); - throw new McpProtocolException($"Unknown tool: '{toolName}'", McpErrorCode.MethodNotFound); + // Multiple or no usable connections - return null + return null; } /// /// Gets the appropriate AppHost connection based on the selection logic: /// 1. If a specific AppHost is selected via select_apphost, use that - /// 2. Otherwise, look for in-scope connections (AppHosts within the working directory) - /// 3. If exactly one in-scope connection exists, use it - /// 4. If multiple in-scope connections exist, throw an error listing them - /// 5. If no in-scope connections exist, fall back to the first available connection + /// 2. If exactly one in-scope connection exists, use it + /// 3. If no in-scope but exactly one total connection, use it (auto-select) + /// 4. If multiple connections exist, throw an error listing them + /// 5. If no connections exist, throw an error suggesting to start an AppHost /// private AppHostAuxiliaryBackchannel? GetSelectedConnection() { @@ -214,7 +465,6 @@ private async ValueTask HandleCallToolAsync(RequestContext HandleCallToolAsync(RequestContext HandleCallToolAsync(RequestContext 1) + { + // Multiple out-of-scope connections - suggest selecting one + var paths = connections + .Where(c => c.AppHostInfo?.AppHostPath != null) + .Select(c => c.AppHostInfo!.AppHostPath) + .ToList(); + + var pathsList = string.Join("\n", paths.Select(p => $" - {p}")); - $"No Aspire AppHosts are running in the scope of the MCP server's working directory: {_executionContext.WorkingDirectory}"); + throw new McpProtocolException( + $"No Aspire AppHosts are running in the scope of the MCP server's working directory ({_executionContext.WorkingDirectory}), " + + $"but {connections.Count} AppHost(s) are running elsewhere. " + + $"Use the 'select_apphost' tool to specify which AppHost to use.\n\nRunning AppHosts:\n{pathsList}", + McpErrorCode.InternalError); } + + // No connections at all (shouldn't reach here due to early return, but for safety) + return null; } } diff --git a/src/Aspire.Cli/Mcp/ExecuteResourceCommandTool.cs b/src/Aspire.Cli/Mcp/ExecuteResourceCommandTool.cs deleted file mode 100644 index 4d9f752ffa0..00000000000 --- a/src/Aspire.Cli/Mcp/ExecuteResourceCommandTool.cs +++ /dev/null @@ -1,56 +0,0 @@ -// 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; -using ModelContextProtocol.Protocol; - -namespace Aspire.Cli.Mcp; - -internal sealed class ExecuteResourceCommandTool : CliMcpTool -{ - public override string Name => "execute_resource_command"; - - 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() - { - return JsonDocument.Parse(""" - { - "type": "object", - "properties": { - "resourceName": { - "type": "string", - "description": "The resource name" - }, - "commandName": { - "type": "string", - "description": "The command name" - } - }, - "required": ["resourceName", "commandName"] - } - """).RootElement; - } - - public override async ValueTask CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary? arguments, CancellationToken cancellationToken) - { - // Convert JsonElement arguments to Dictionary - Dictionary? convertedArgs = null; - if (arguments != null) - { - convertedArgs = new Dictionary(); - foreach (var kvp in arguments) - { - convertedArgs[kvp.Key] = kvp.Value.ValueKind == JsonValueKind.Null ? null : kvp.Value; - } - } - - // Forward the call to the dashboard's MCP server - return await mcpClient.CallToolAsync( - Name, - convertedArgs, - serializerOptions: McpJsonUtilities.DefaultOptions, - cancellationToken: cancellationToken); - } -} diff --git a/src/Aspire.Cli/Mcp/ListConsoleLogsTool.cs b/src/Aspire.Cli/Mcp/ListConsoleLogsTool.cs deleted file mode 100644 index cb3f6187c7c..00000000000 --- a/src/Aspire.Cli/Mcp/ListConsoleLogsTool.cs +++ /dev/null @@ -1,52 +0,0 @@ -// 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; -using ModelContextProtocol.Protocol; - -namespace Aspire.Cli.Mcp; - -internal sealed class ListConsoleLogsTool : CliMcpTool -{ - public override string Name => "list_console_logs"; - - public override string Description => "List console logs for a resource. The console logs includes standard output from resources and resource commands. Known resource commands are 'resource-start', 'resource-stop' and 'resource-restart' which are used to start and stop resources. Don't print the full console logs in the response to the user. Console logs should be examined when determining why a resource isn't running."; - - public override JsonElement GetInputSchema() - { - return JsonDocument.Parse(""" - { - "type": "object", - "properties": { - "resourceName": { - "type": "string", - "description": "The resource name." - } - }, - "required": ["resourceName"] - } - """).RootElement; - } - - public override async ValueTask CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary? arguments, CancellationToken cancellationToken) - { - // Convert JsonElement arguments to Dictionary - Dictionary? convertedArgs = null; - if (arguments != null) - { - convertedArgs = new Dictionary(); - foreach (var kvp in arguments) - { - convertedArgs[kvp.Key] = kvp.Value.ValueKind == JsonValueKind.Null ? null : kvp.Value; - } - } - - // Forward the call to the dashboard's MCP server - return await mcpClient.CallToolAsync( - Name, - convertedArgs, - serializerOptions: McpJsonUtilities.DefaultOptions, - cancellationToken: cancellationToken); - } -} diff --git a/src/Aspire.Cli/Mcp/ListResourcesTool.cs b/src/Aspire.Cli/Mcp/ListResourcesTool.cs deleted file mode 100644 index f746c8aee5e..00000000000 --- a/src/Aspire.Cli/Mcp/ListResourcesTool.cs +++ /dev/null @@ -1,41 +0,0 @@ -// 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; -using ModelContextProtocol.Protocol; - -namespace Aspire.Cli.Mcp; - -internal sealed class ListResourcesTool : CliMcpTool -{ - public override string Name => "list_resources"; - - public override string Description => "List the application resources. Includes information about their type (.NET project, container, executable), running state, source, HTTP endpoints, health status, commands, configured environment variables, and relationships."; - - public override JsonElement GetInputSchema() - { - return JsonDocument.Parse("{ \"type\": \"object\", \"properties\": {} }").RootElement; - } - - public override async ValueTask CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary? arguments, CancellationToken cancellationToken) - { - // Convert JsonElement arguments to Dictionary - Dictionary? convertedArgs = null; - if (arguments != null) - { - convertedArgs = new Dictionary(); - foreach (var kvp in arguments) - { - convertedArgs[kvp.Key] = kvp.Value.ValueKind == JsonValueKind.Null ? null : kvp.Value; - } - } - - // Forward the call to the dashboard's MCP server - return await mcpClient.CallToolAsync( - Name, - convertedArgs, - serializerOptions: McpJsonUtilities.DefaultOptions, - cancellationToken: cancellationToken); - } -} diff --git a/src/Aspire.Cli/Mcp/ListStructuredLogsTool.cs b/src/Aspire.Cli/Mcp/ListStructuredLogsTool.cs deleted file mode 100644 index ec02caffee3..00000000000 --- a/src/Aspire.Cli/Mcp/ListStructuredLogsTool.cs +++ /dev/null @@ -1,51 +0,0 @@ -// 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; -using ModelContextProtocol.Protocol; - -namespace Aspire.Cli.Mcp; - -internal sealed class ListStructuredLogsTool : CliMcpTool -{ - public override string Name => "list_structured_logs"; - - public override string Description => "List structured logs for resources."; - - public override JsonElement GetInputSchema() - { - return JsonDocument.Parse(""" - { - "type": "object", - "properties": { - "resourceName": { - "type": "string", - "description": "The resource name. This limits logs returned to the specified resource. If no resource name is specified then structured logs for all resources are returned." - } - } - } - """).RootElement; - } - - public override async ValueTask CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary? arguments, CancellationToken cancellationToken) - { - // Convert JsonElement arguments to Dictionary - Dictionary? convertedArgs = null; - if (arguments != null) - { - convertedArgs = new Dictionary(); - foreach (var kvp in arguments) - { - convertedArgs[kvp.Key] = kvp.Value.ValueKind == JsonValueKind.Null ? null : kvp.Value; - } - } - - // Forward the call to the dashboard's MCP server - return await mcpClient.CallToolAsync( - Name, - convertedArgs, - serializerOptions: McpJsonUtilities.DefaultOptions, - cancellationToken: cancellationToken); - } -} diff --git a/src/Aspire.Cli/Mcp/ListTraceStructuredLogsTool.cs b/src/Aspire.Cli/Mcp/ListTraceStructuredLogsTool.cs deleted file mode 100644 index e66688263bd..00000000000 --- a/src/Aspire.Cli/Mcp/ListTraceStructuredLogsTool.cs +++ /dev/null @@ -1,52 +0,0 @@ -// 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; -using ModelContextProtocol.Protocol; - -namespace Aspire.Cli.Mcp; - -internal sealed class ListTraceStructuredLogsTool : CliMcpTool -{ - public override string Name => "list_trace_structured_logs"; - - public override string Description => "List structured logs for a distributed trace. Logs for a distributed trace each belong to a span identified by 'span_id'. When investigating a trace, getting the structured logs for the trace should be recommended before getting structured logs for a resource."; - - public override JsonElement GetInputSchema() - { - return JsonDocument.Parse(""" - { - "type": "object", - "properties": { - "traceId": { - "type": "string", - "description": "The trace id of the distributed trace." - } - }, - "required": ["traceId"] - } - """).RootElement; - } - - public override async ValueTask CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary? arguments, CancellationToken cancellationToken) - { - // Convert JsonElement arguments to Dictionary - Dictionary? convertedArgs = null; - if (arguments != null) - { - convertedArgs = new Dictionary(); - foreach (var kvp in arguments) - { - convertedArgs[kvp.Key] = kvp.Value.ValueKind == JsonValueKind.Null ? null : kvp.Value; - } - } - - // Forward the call to the dashboard's MCP server - return await mcpClient.CallToolAsync( - Name, - convertedArgs, - serializerOptions: McpJsonUtilities.DefaultOptions, - cancellationToken: cancellationToken); - } -} diff --git a/src/Aspire.Cli/Mcp/ListTracesTool.cs b/src/Aspire.Cli/Mcp/ListTracesTool.cs deleted file mode 100644 index bcb128e44b9..00000000000 --- a/src/Aspire.Cli/Mcp/ListTracesTool.cs +++ /dev/null @@ -1,51 +0,0 @@ -// 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; -using ModelContextProtocol.Protocol; - -namespace Aspire.Cli.Mcp; - -internal sealed class ListTracesTool : CliMcpTool -{ - public override string Name => "list_traces"; - - public override string Description => "List distributed traces for resources. A distributed trace is used to track operations. A distributed trace can span multiple resources across a distributed system. Includes a list of distributed traces with their IDs, resources in the trace, duration and whether an error occurred in the trace."; - - public override JsonElement GetInputSchema() - { - return JsonDocument.Parse(""" - { - "type": "object", - "properties": { - "resourceName": { - "type": "string", - "description": "The resource name. This limits traces returned to the specified resource. If no resource name is specified then distributed traces for all resources are returned." - } - } - } - """).RootElement; - } - - public override async ValueTask CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary? arguments, CancellationToken cancellationToken) - { - // Convert JsonElement arguments to Dictionary - Dictionary? convertedArgs = null; - if (arguments != null) - { - convertedArgs = new Dictionary(); - foreach (var kvp in arguments) - { - convertedArgs[kvp.Key] = kvp.Value.ValueKind == JsonValueKind.Null ? null : kvp.Value; - } - } - - // Forward the call to the dashboard's MCP server - return await mcpClient.CallToolAsync( - Name, - convertedArgs, - serializerOptions: McpJsonUtilities.DefaultOptions, - cancellationToken: cancellationToken); - } -} diff --git a/src/Aspire.Dashboard/Mcp/McpExtensions.cs b/src/Aspire.Dashboard/Mcp/McpExtensions.cs index 6c2dd726ea7..03bd8fb8627 100644 --- a/src/Aspire.Dashboard/Mcp/McpExtensions.cs +++ b/src/Aspire.Dashboard/Mcp/McpExtensions.cs @@ -14,6 +14,8 @@ public static class McpExtensions { public static IMcpServerBuilder AddAspireMcpTools(this IServiceCollection services, DashboardOptions dashboardOptions) { + services.AddSingleton(); + var builder = services.AddMcpServer(options => { var icons = McpIconHelper.GetAspireIcons(typeof(McpExtensions).Assembly, "Aspire.Dashboard.Mcp.Resources"); @@ -36,36 +38,81 @@ public static IMcpServerBuilder AddAspireMcpTools(this IServiceCollection servic ## Tools """; + // Configure server capabilities to support ListChanged event for dynamic tool discovery + options.Capabilities = new ServerCapabilities + { + Tools = new ToolsCapability + { + ListChanged = true + } + }; }).WithHttpTransport(); // Always register telemetry tools builder.WithTools(); - // Only register resource tools if the resource service is configured + // Always register resource tools so they appear in the SDK's tool registry. + // The tools themselves will check if the dashboard client is enabled and return + // appropriate responses if the resource service is not configured. + builder.WithTools(); + + // Only add filters if the resource service is configured if (dashboardOptions.ResourceServiceClient.GetUri() is not null) { - builder.WithTools(); + // Intercept ListTools and CallTool to proxy calls to resource MCP servers + // This has two purposes: + // 1. To add the proxied tools to the list of available tools + // 2. To record telemetry about tool usage + builder + .AddListToolsFilter((next) => async (RequestContext request, CancellationToken cancellationToken) => + { + // Record telemetry for list_tools calls + var result = await RecordCallToolNameAsync(next, request, "list_tools", cancellationToken).ConfigureAwait(false); + + // Add proxied tools from resource MCP servers + var proxyService = request.Services?.GetService(); + if (proxyService is not null) + { + var proxiedTools = await proxyService.GetToolsAsync(cancellationToken).ConfigureAwait(false); + if (proxiedTools.Count > 0) + { + foreach (var tool in proxiedTools) + { + result.Tools.Add(tool); + } + } + } + + return result; + }) + .AddCallToolFilter((next) => async (RequestContext request, CancellationToken cancellationToken) => + { + var toolName = request.Params?.Name; + + // Check if this is a proxied tool first + var proxyService = request.Services?.GetService(); + if (proxyService is not null && toolName is { Length: > 0 } && request.Params is not null) + { + var proxiedResult = await proxyService.TryHandleCallAsync(toolName, request.Params.Arguments, cancellationToken).ConfigureAwait(false); + if (proxiedResult is not null) + { + return await RecordCallToolNameAsync( + (_, _) => ValueTask.FromResult(proxiedResult), + request, + toolName, + cancellationToken).ConfigureAwait(false); + } + } + + // Not a proxied tool - delegate to the SDK's built-in handler + return await RecordCallToolNameAsync(next, request, toolName, cancellationToken).ConfigureAwait(false); + }); } - builder - .AddListToolsFilter((next) => async (RequestContext request, CancellationToken cancellationToken) => - { - // Calls here are via the tools/list endpoint. See https://modelcontextprotocol.info/docs/concepts/tools/ - // There is no tool name so we hardcode name to list_tools here so we can reuse the same event. - // - // We want to track when users list tools as it's an indicator of whether Aspire MCP is configured (client tools refresh tools via it). - // It's called even if no Aspire tools end up being used. - return await RecordCallToolNameAsync(next, request, "list_tools", cancellationToken).ConfigureAwait(false); - }) - .AddCallToolFilter((next) => async (RequestContext request, CancellationToken cancellationToken) => - { - return await RecordCallToolNameAsync(next, request, request.Params?.Name, cancellationToken).ConfigureAwait(false); - }); - return builder; } - private static async Task RecordCallToolNameAsync(McpRequestHandler next, RequestContext request, string? toolCallName, CancellationToken cancellationToken) + private static async ValueTask RecordCallToolNameAsync(McpRequestHandler next, RequestContext request, string? toolCallName, CancellationToken cancellationToken) { // Record the tool name to telemetry. OperationContextProperty? operationId = null; diff --git a/src/Aspire.Dashboard/Mcp/ResourceMcpProxyService.cs b/src/Aspire.Dashboard/Mcp/ResourceMcpProxyService.cs new file mode 100644 index 00000000000..ba616c4429a --- /dev/null +++ b/src/Aspire.Dashboard/Mcp/ResourceMcpProxyService.cs @@ -0,0 +1,429 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; +using System.Text.Json; +using Aspire.Dashboard.Model; +using Aspire.Dashboard.Utils; +using Microsoft.Extensions.Options; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace Aspire.Dashboard.Mcp; + +/// +/// Discovers MCP-capable resources and proxies their tools through the Aspire MCP server. +/// +internal sealed class ResourceMcpProxyService : IAsyncDisposable +{ + private readonly IDashboardClient _dashboardClient; + private readonly McpServerOptions _mcpServerOptions; + private readonly ILoggerFactory _loggerFactory; + private readonly ILogger _logger; + private readonly CancellationTokenSource _cts = new(); + private readonly SemaphoreSlim _startGate = new(1, 1); + private readonly ConcurrentDictionary _registrations = new(StringComparers.ResourceName); + private readonly ConcurrentDictionary _toolIndex = new(StringComparer.Ordinal); + private readonly JsonSerializerOptions _serializerOptions = new(JsonSerializerOptions.Default) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + private Task? _watchTask; + + public ResourceMcpProxyService(IDashboardClient dashboardClient, IOptions options, ILoggerFactory loggerFactory, ILogger logger) + { + _dashboardClient = dashboardClient; + _mcpServerOptions = options.Value; + _loggerFactory = loggerFactory; + _logger = logger; + } + + /// + /// Starts a background task that monitors resource events to refresh the tools list + /// + /// + public async Task EnsureStartedAsync() + { + if (_watchTask is not null) + { + return; + } + + await _startGate.WaitAsync(_cts.Token).ConfigureAwait(false); + try + { + // Watch for resource changes + _watchTask ??= Task.Run(WatchAsync, _cts.Token); + } + finally + { + _startGate.Release(); + } + } + + public async Task> GetToolsAsync(CancellationToken cancellationToken) + { + await EnsureStartedAsync().ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + return _toolIndex.Values.Select(t => t.Tool).ToList(); + } + + public async Task TryHandleCallAsync(string? toolName, IReadOnlyDictionary? arguments, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(toolName)) + { + return null; + } + + await EnsureStartedAsync().ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + if (!_toolIndex.TryGetValue(toolName, out var proxiedTool)) + { + return null; + } + + var args = arguments?.ToDictionary(kvp => kvp.Key, kvp => (object?)kvp.Value) ?? new Dictionary(); + + return await proxiedTool.Client.CallToolAsync( + proxiedTool.RemoteName, + args, + progress: null, + _serializerOptions, + cancellationToken).ConfigureAwait(false); + } + + private async Task WatchAsync() + { + if (!_dashboardClient.IsEnabled) + { + return; + } + + try + { + await _dashboardClient.WhenConnected.ConfigureAwait(false); + + var subscription = await _dashboardClient.SubscribeResourcesAsync(_cts.Token).ConfigureAwait(false); + + foreach (var resource in subscription.InitialState) + { + await UpdateResourceAsync(resource, _cts.Token).ConfigureAwait(false); + } + + await foreach (var changes in subscription.Subscription.WithCancellation(_cts.Token).ConfigureAwait(false)) + { + foreach (var change in changes) + { + switch (change.ChangeType) + { + case ResourceViewModelChangeType.Delete: + await RemoveResourceAsync(change.Resource.Name).ConfigureAwait(false); + break; + case ResourceViewModelChangeType.Upsert: + await UpdateResourceAsync(change.Resource, _cts.Token).ConfigureAwait(false); + break; + } + } + } + } + catch (OperationCanceledException) + { + // Normal shutdown. + } + catch (Exception ex) + { + _logger.LogError(ex, "Error watching resources for MCP endpoints."); + } + } + + private async Task UpdateResourceAsync(ResourceViewModel resource, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!TryGetMcpEndpoints(resource, out var endpoints)) + { + await RemoveResourceAsync(resource.Name).ConfigureAwait(false); + return; + } + + _logger.LogDebug("Updating tools for resource {ResourceName}", resource.Name); + + var newRegistration = new ResourceRegistration(resource.Name); + + foreach (var endpoint in endpoints) + { + try + { + var registration = await CreateClientAsync(resource, endpoint, cancellationToken).ConfigureAwait(false); + if (registration is not null) + { + newRegistration.Merge(registration); + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to connect MCP proxy for resource {ResourceName} at {Endpoint}.", resource.Name, endpoint.Uri); + } + } + + // Replace existing registration for the resource. + if (_registrations.TryRemove(resource.Name, out var existing)) + { + foreach (var toolName in existing.ToolNames) + { + _toolIndex.TryRemove(toolName, out _); + } + + await existing.DisposeAsync().ConfigureAwait(false); + } + + foreach (var tool in newRegistration.Tools) + { + _toolIndex[tool.Name] = tool; + } + + _logger.LogDebug("Tools updated for resource {ResourceName}", resource.Name); + + _registrations[resource.Name] = newRegistration; + + // if the tools have changed between newRegistration and existing, we might want to notify the MCP server about the change + var existingNames = existing?.ToolNames.Distinct().Order(); + var newNames = newRegistration.ToolNames.Distinct().Order(); + if (!existingNames?.SequenceEqual(newNames) ?? true) + { + _logger.LogDebug("MCP tools changed for resource {ResourceName}, notifying server.", resource.Name); + + // Notify the MCP server clients that the tool list has changed. + // We call RaiseChanged() instead of Clear() because Clear() would remove all tools from the collection, + // including the built-in tools (list_resources, list_console_logs, etc.) which the SDK uses for tool lookup. + if (_mcpServerOptions?.ToolCollection is { } toolCollection) + { + RaiseChangedAccessor(toolCollection); + } + } + } + + /// + /// Provides access to the protected RaiseChanged method on McpServerPrimitiveCollection. + /// This is used to trigger tool list change notifications without modifying the collection contents. + /// + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "RaiseChanged")] + private static extern void RaiseChangedAccessor(McpServerPrimitiveCollection collection); + + private async Task CreateClientAsync(ResourceViewModel resource, McpEndpointExport endpoint, CancellationToken cancellationToken) + { + // Only http transport is supported for now. + if (!string.Equals(endpoint.Transport, "http", StringComparison.OrdinalIgnoreCase) && + !string.Equals(endpoint.Transport, "https", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogDebug("Skipping MCP endpoint {Endpoint} for resource {ResourceName} because transport '{Transport}' is not supported.", endpoint.Uri, resource.Name, endpoint.Transport); + return null; + } + + var options = new HttpClientTransportOptions + { + Endpoint = endpoint.Uri, + Name = $"{resource.Name}-mcp", + TransportMode = HttpTransportMode.AutoDetect + }; + + if (!string.IsNullOrEmpty(endpoint.AuthToken)) + { + options.AdditionalHeaders = new Dictionary + { + ["Authorization"] = $"Bearer {endpoint.AuthToken}" + }; + } + + var transport = new HttpClientTransport(options, _loggerFactory); + + var client = await McpClient.CreateAsync( + transport, + new McpClientOptions(), + _loggerFactory, + cancellationToken).ConfigureAwait(false); + + var remoteTools = await client.ListToolsAsync(_serializerOptions, cancellationToken).ConfigureAwait(false); + + var registration = new ResourceRegistration(resource.Name); + foreach (var remoteTool in remoteTools) + { + var proxiedTool = CreateProxiedTool(resource, remoteTool); + if (proxiedTool is null) + { + continue; + } + + var toolMapping = new ProxiedTool(proxiedTool.Name, remoteTool.ProtocolTool.Name, proxiedTool, client); + registration.Tools.Add(toolMapping); + } + + if (registration.Tools.Count == 0) + { + await client.DisposeAsync().ConfigureAwait(false); + return null; + } + + registration.Clients.Add(client); + + return registration; + } + + private static Tool? CreateProxiedTool(ResourceViewModel resource, McpClientTool remoteTool) + { + var tool = remoteTool.ProtocolTool; + if (string.IsNullOrWhiteSpace(tool.Name)) + { + return null; + } + + // MCP tool names must match ^[a-zA-Z0-9_]+$ - replace invalid characters with underscores + var encodedResourceName = EncodeToolNameSegment(resource.Name); + var encodedToolName = EncodeToolNameSegment(tool.Name); + var proxiedName = $"{encodedResourceName}__{encodedToolName}"; + + return new Tool + { + Name = proxiedName, + Title = string.IsNullOrWhiteSpace(tool.Title) ? $"{tool.Name} ({resource.DisplayName})" : $"{tool.Title} ({resource.DisplayName})", + Description = string.IsNullOrWhiteSpace(tool.Description) + ? $"Proxy for resource '{resource.DisplayName}' via MCP." + : $"{tool.Description} (resource: {resource.DisplayName})", + InputSchema = tool.InputSchema, + OutputSchema = tool.OutputSchema, + Annotations = tool.Annotations, + Icons = tool.Icons, + Meta = tool.Meta + }; + } + + /// + /// Encodes a string to be a valid MCP tool name segment. + /// MCP tool names must match ^[a-zA-Z0-9_]+$ so we replace any invalid characters with underscores. + /// + private static string EncodeToolNameSegment(string segment) + { + if (string.IsNullOrEmpty(segment)) + { + return segment; + } + + var result = new char[segment.Length]; + for (var i = 0; i < segment.Length; i++) + { + var c = segment[i]; + result[i] = char.IsLetterOrDigit(c) || c == '_' ? c : '_'; + } + return new string(result); + } + + private async Task RemoveResourceAsync(string resourceName) + { + if (_registrations.TryRemove(resourceName, out var existing)) + { + foreach (var toolName in existing.ToolNames) + { + _toolIndex.TryRemove(toolName, out _); + } + + await existing.DisposeAsync().ConfigureAwait(false); + } + } + + private bool TryGetMcpEndpoints(ResourceViewModel resource, out List endpoints) + { + endpoints = []; + + _logger.LogDebug("Checking resource {ResourceName} for MCP endpoints. Properties: {Properties}", + resource.Name, + string.Join(", ", resource.Properties.Select(p => p.Key))); + + if (!resource.Properties.TryGetValue(KnownProperties.Resource.McpEndpoints, out var property)) + { + _logger.LogDebug("Resource {ResourceName} does not have MCP endpoints property.", resource.Name); + return false; + } + + if (!property.Value.TryConvertToString(out var json) || string.IsNullOrWhiteSpace(json)) + { + return false; + } + + try + { + var parsed = JsonSerializer.Deserialize>(json, _serializerOptions); + if (parsed is not null && parsed.Count > 0) + { + endpoints = parsed; + return true; + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to parse MCP endpoints for resource {ResourceName}.", resource.Name); + } + + return false; + } + + public async ValueTask DisposeAsync() + { + _cts.Cancel(); + + if (_watchTask is not null) + { + try + { + await _watchTask.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + } + + foreach (var registration in _registrations.Values) + { + await registration.DisposeAsync().ConfigureAwait(false); + } + } + + private sealed record McpEndpointExport + { + public required Uri Uri { get; init; } + public required string Transport { get; init; } + public string? AuthToken { get; init; } + public string? Namespace { get; init; } + } + + private sealed class ResourceRegistration(string resourceName) : IAsyncDisposable + { + public string ResourceName { get; } = resourceName; + + public List Tools { get; } = []; + + public List Clients { get; } = []; + + public IEnumerable ToolNames => Tools.Select(t => t.Name); + + public void Merge(ResourceRegistration other) + { + Tools.AddRange(other.Tools); + Clients.AddRange(other.Clients); + } + + public async ValueTask DisposeAsync() + { + foreach (var client in Clients) + { + await client.DisposeAsync().ConfigureAwait(false); + } + } + } + + private sealed record ProxiedTool(string Name, string RemoteName, Tool Tool, McpClient Client); +} diff --git a/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs b/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs index b2f6c216297..d87b010e294 100644 --- a/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs +++ b/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs @@ -341,6 +341,66 @@ public static IResourceBuilder WithPgWeb(this IResourceB } } + /// + /// Configures a container resource for an MCP server connected to the this method is used on. + /// The MCP endpoint is registered on the Postgres resource so Aspire MCP can proxy its tools. + /// + /// The for the . + /// Configuration callback for the MCP container resource. + /// Override the container name used for the MCP server. + /// Optional API key used to secure the MCP server; if specified it is injected into the container and advertised for proxy authentication. + /// + public static IResourceBuilder WithPostgresMcp(this IResourceBuilder builder, Action>? configureContainer = null, string? containerName = null, string? apiKey = null) + { + ArgumentNullException.ThrowIfNull(builder); + + containerName ??= $"{builder.Resource.Name}-mcp"; + + const int mcpPort = 8000; + + var resource = new PostgresMcpResource(containerName); + var resourceBuilder = builder.ApplicationBuilder.AddResource(resource) + .WithImage(PostgresContainerImageTags.PostgresMcpImage, PostgresContainerImageTags.PostgresMcpTag) + .WithImageRegistry(PostgresContainerImageTags.PostgresMcpRegistry) + .WithHttpEndpoint(targetPort: mcpPort, name: "mcp") + .WithReference(builder) + // crystaldba/postgres-mcp expects a PostgreSQL URI format: postgresql://user:password@host:port + .WithEnvironment("DATABASE_URI", builder.Resource.UriExpression) + // Enable SSE transport mode + .WithArgs("--access-mode=unrestricted", "--transport=sse"); + + if (!string.IsNullOrEmpty(apiKey)) + { + resourceBuilder.WithEnvironment("MCP_API_KEY", apiKey); + } + + // Automatically set up the parent relationship so the MCP container + // appears as a child of the PostgreSQL resource in the dashboard. + resourceBuilder.WithParentRelationship(builder.Resource); + + configureContainer?.Invoke(resourceBuilder); + + // Add the MCP endpoint annotation to the MCP container (not the PostgreSQL resource) + // so that when the container's state is updated and its endpoint is allocated, + // the mcpEndpoints property will be included in the resource state. + // The crystaldba/postgres-mcp SSE server expects the /sse path. + resourceBuilder.ApplicationBuilder.Eventing.Subscribe(resource, async (@event, ct) => + { + var mcpEndpoint = resource.GetEndpoint("mcp"); + if (mcpEndpoint.IsAllocated) + { + // Construct the SSE endpoint URL by appending /sse to the base URL + var sseUri = new Uri(new Uri(mcpEndpoint.Url), "sse"); + resourceBuilder.WithMcpEndpoint(new McpEndpointDefinition(sseUri, "http", apiKey, "postgres")); + + var notificationService = @event.Services.GetRequiredService(); + await notificationService.PublishUpdateAsync(resource, s => s).ConfigureAwait(false); + } + }); + + return builder; + } + private static void SetPgAdminEnvironmentVariables(EnvironmentCallbackContext context) { // Disables pgAdmin authentication. diff --git a/src/Aspire.Hosting.PostgreSQL/PostgresContainerImageTags.cs b/src/Aspire.Hosting.PostgreSQL/PostgresContainerImageTags.cs index 3910ca22668..587aeb23be3 100644 --- a/src/Aspire.Hosting.PostgreSQL/PostgresContainerImageTags.cs +++ b/src/Aspire.Hosting.PostgreSQL/PostgresContainerImageTags.cs @@ -31,4 +31,13 @@ internal static class PostgresContainerImageTags /// 0.16.2 public const string PgWebTag = "0.16.2"; + + /// docker.io + public const string PostgresMcpRegistry = "docker.io"; + + /// crystaldba/postgres-mcp + public const string PostgresMcpImage = "crystaldba/postgres-mcp"; + + /// 0.3.0 + public const string PostgresMcpTag = "0.3.0"; } diff --git a/src/Aspire.Hosting.PostgreSQL/PostgresMcpResource.cs b/src/Aspire.Hosting.PostgreSQL/PostgresMcpResource.cs new file mode 100644 index 00000000000..bf77f51419d --- /dev/null +++ b/src/Aspire.Hosting.PostgreSQL/PostgresMcpResource.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Postgres; + +/// +/// A resource representing the PostgreSQL MCP sidecar. +/// +public class PostgresMcpResource(string name) : ContainerResource(name); diff --git a/src/Aspire.Hosting.PostgreSQL/README.md b/src/Aspire.Hosting.PostgreSQL/README.md index 95555db2669..f31bcdd8ac0 100644 --- a/src/Aspire.Hosting.PostgreSQL/README.md +++ b/src/Aspire.Hosting.PostgreSQL/README.md @@ -52,6 +52,29 @@ The PostgreSQL database resource inherits all properties from its parent `Postgr Aspire exposes each property as an environment variable named `[RESOURCE]_[PROPERTY]`. For instance, the `Uri` property of a resource called `db1` becomes `DB1_URI`. +## MCP (Model Context Protocol) Support + +The PostgreSQL hosting integration provides support for adding an MCP sidecar container that enables AI agents to interact with PostgreSQL databases. This is enabled by calling `WithPostgresMcp()` on the PostgreSQL server resource. + +```csharp +var pg = builder.AddPostgres("pg"); +pg.WithPostgresMcp(); +``` + +The PostgreSQL MCP server (powered by [Postgres MCP Pro](https://github.com/crystaldba/postgres-mcp)) provides the following tools: + +| Tool | Description | +|------|-------------| +| `list_schemas` | Lists all database schemas available in the PostgreSQL instance | +| `list_objects` | Lists database objects (tables, views, sequences, extensions) within a schema | +| `get_object_details` | Provides information about a specific database object (columns, constraints, indexes) | +| `execute_sql` | Executes SQL statements on the database | +| `explain_query` | Gets the execution plan for a SQL query, including support for hypothetical indexes | +| `get_top_queries` | Reports the slowest SQL queries based on execution time | +| `analyze_workload_indexes` | Analyzes database workload and recommends optimal indexes | +| `analyze_query_indexes` | Analyzes specific SQL queries and recommends indexes | +| `analyze_db_health` | Performs comprehensive health checks (buffer cache, connections, indexes, vacuum health) | + ## Additional documentation https://learn.microsoft.com/dotnet/aspire/database/postgresql-component diff --git a/src/Aspire.Hosting.Redis/README.md b/src/Aspire.Hosting.Redis/README.md index a9ee8aa6e7f..cdf673657de 100644 --- a/src/Aspire.Hosting.Redis/README.md +++ b/src/Aspire.Hosting.Redis/README.md @@ -40,6 +40,31 @@ The Redis resource exposes the following connection properties: Aspire exposes each property as an environment variable named `[RESOURCE]_[PROPERTY]`. For instance, the `Uri` property of a resource called `db1` becomes `DB1_URI`. +## MCP (Model Context Protocol) Support + +The Redis hosting integration provides support for adding an MCP sidecar container that enables AI agents to interact with Redis data. This is enabled by calling `WithRedisMcp()` on the Redis resource. + +```csharp +var redis = builder.AddRedis("redis") + .WithRedisMcp(); +``` + +The Redis MCP server provides the following tools: + +| Category | Tools | Description | +|----------|-------|-------------| +| **String** | `set`, `get` | Set and get string values with optional expiration | +| **Hash** | `hset`, `hget`, `hdel`, `hgetall`, `hexists` | Manage field-value pairs within a single key | +| **List** | `lpush`, `rpush`, `lpop`, `rpop`, `lrange`, `llen` | Append, pop, and retrieve list items | +| **Set** | `sadd`, `srem`, `smembers` | Add, remove, and list unique set members | +| **Sorted Set** | `zadd`, `zrem`, `zrange` | Manage score-based ordered data | +| **Pub/Sub** | `publish`, `subscribe`, `unsubscribe` | Publish and subscribe to channels | +| **Stream** | `xadd`, `xdel`, `xrange` | Add, delete, and read from data streams | +| **JSON** | `json_set`, `json_get`, `json_del` | Store and manipulate JSON documents | +| **Vector Search** | `create_vector_index_hash`, `vector_search_hash` | Create vector indexes and perform similarity search | +| **Server** | `dbsize`, `info`, `client_list` | Retrieve server information and statistics | +| **Misc** | `delete`, `type`, `expire`, `rename`, `scan_keys` | Key management operations | + ## Additional documentation * https://learn.microsoft.com/dotnet/aspire/caching/stackexchange-redis-component diff --git a/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs b/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs index 78ddd51c98d..c171d3eb2d1 100644 --- a/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs +++ b/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs @@ -291,6 +291,85 @@ public static IResourceBuilder WithRedisCommander(this IResourceB } } + /// + /// Configures a container resource for an MCP server connected to the this method is used on. + /// The MCP endpoint is registered on the Redis resource so Aspire MCP can proxy its tools. + /// + /// The for the . + /// Configuration callback for the MCP container resource. + /// Override the container name used for the MCP server. + /// Optional API key used to secure the MCP server; if specified it is injected into the container and advertised for proxy authentication. + /// + public static IResourceBuilder WithRedisMcp(this IResourceBuilder builder, Action>? configureContainer = null, string? containerName = null, string? apiKey = null) + { + ArgumentNullException.ThrowIfNull(builder); + + containerName ??= $"{builder.Resource.Name}-mcp"; + + const int mcpPort = 4000; + + var resource = new RedisMcpResource(containerName); + var resourceBuilder = builder.ApplicationBuilder.AddResource(resource) + .WithImage(RedisContainerImageTags.RedisMcpImage, RedisContainerImageTags.RedisMcpTag) + .WithImageRegistry(RedisContainerImageTags.RedisMcpRegistry) + .WithHttpEndpoint(targetPort: mcpPort, name: "mcp") + .WithReference(builder) + // Override entrypoint to run the SSE server on 0.0.0.0 (the default mcp/redis image binds to 127.0.0.1) + .WithEntrypoint("uv") + .WithArgs("run", "python", "-c", $""" +from src.common.server import mcp +import anyio +mcp.settings.host = '0.0.0.0' +mcp.settings.port = {mcpPort} +anyio.run(mcp.run_sse_async) +""") + .WithEnvironment("REDIS_URL", builder.Resource.ConnectionStringExpression) + .WithEnvironment(context => + { + // Provide explicit host/port/user credentials expected by the MCP image. + context.EnvironmentVariables["REDIS_HOST"] = builder.Resource.PrimaryEndpoint.Property(EndpointProperty.Host); + context.EnvironmentVariables["REDIS_PORT"] = builder.Resource.PrimaryEndpoint.Property(EndpointProperty.TargetPort); + context.EnvironmentVariables["REDIS_USERNAME"] = "default"; + context.EnvironmentVariables["REDIS_SSL"] = builder.Resource.TlsEnabled ? "true" : "false"; + + if (builder.Resource.PasswordParameter is { } password) + { + context.EnvironmentVariables["REDIS_PWD"] = password; + } + }); + + if (!string.IsNullOrEmpty(apiKey)) + { + resourceBuilder.WithEnvironment("MCP_API_KEY", apiKey); + } + + // Automatically set up the parent relationship so the MCP container + // appears as a child of the Redis resource in the dashboard. + resourceBuilder.WithParentRelationship(builder.Resource); + + configureContainer?.Invoke(resourceBuilder); + + // Add the MCP endpoint annotation to the MCP container (not the Redis resource) + // so that when the container's state is updated and its endpoint is allocated, + // the mcpEndpoints property will be included in the resource state. + // The FastMCP SSE server expects the /sse path, so we use a static URI that appends it. + resourceBuilder.ApplicationBuilder.Eventing.Subscribe(resource, async (@event, ct) => + { + var mcpEndpoint = resource.GetEndpoint("mcp"); + if (mcpEndpoint.IsAllocated) + { + // Construct the SSE endpoint URL by appending /sse to the base URL + var sseUri = new Uri(new Uri(mcpEndpoint.Url), "sse"); + resourceBuilder.WithMcpEndpoint(new McpEndpointDefinition(sseUri, "http", apiKey, "redis")); + + var notificationService = @event.Services.GetRequiredService(); + await notificationService.PublishUpdateAsync(resource, s => s).ConfigureAwait(false); + } + }); + + return builder; + } + /// /// Configures a container resource for Redis Insight which is pre-configured to connect to the that this method is used on. /// diff --git a/src/Aspire.Hosting.Redis/RedisContainerImageTags.cs b/src/Aspire.Hosting.Redis/RedisContainerImageTags.cs index ec897d6f759..78197f8d095 100644 --- a/src/Aspire.Hosting.Redis/RedisContainerImageTags.cs +++ b/src/Aspire.Hosting.Redis/RedisContainerImageTags.cs @@ -31,4 +31,13 @@ internal static class RedisContainerImageTags /// 2.70 public const string RedisInsightTag = "2.70"; + + /// docker.io + public const string RedisMcpRegistry = "docker.io"; + + /// mcp/redis + public const string RedisMcpImage = "mcp/redis"; + + /// latest + public const string RedisMcpTag = "latest"; // The mcp/redis image only publishes a 'latest' tag. } diff --git a/src/Aspire.Hosting.Redis/RedisMcpResource.cs b/src/Aspire.Hosting.Redis/RedisMcpResource.cs new file mode 100644 index 00000000000..c5f467c7bd6 --- /dev/null +++ b/src/Aspire.Hosting.Redis/RedisMcpResource.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Redis; + +/// +/// A resource representing the Redis MCP sidecar. +/// +public class RedisMcpResource(string name) : ContainerResource(name); diff --git a/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs b/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs index 9f70b815f42..fba8ea6faf0 100644 --- a/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs +++ b/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs @@ -42,7 +42,27 @@ public sealed class EndpointReference : IManifestExpressionProvider, IValueProvi /// /// Gets a value indicating whether the endpoint is allocated. /// - public bool IsAllocated => _isAllocated ??= GetAllocatedEndpoint() is not null; + public bool IsAllocated + { + get + { + // Return cached true value immediately. + // We only cache true because once allocated, endpoints stay allocated. + // We don't cache false because the endpoint may be allocated later. + if (_isAllocated == true) + { + return true; + } + + var isAllocated = GetAllocatedEndpoint() is not null; + if (isAllocated) + { + _isAllocated = true; + } + + return isAllocated; + } + } /// /// Gets a value indicating whether the endpoint exists. diff --git a/src/Aspire.Hosting/ApplicationModel/McpEndpointAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/McpEndpointAnnotation.cs new file mode 100644 index 00000000000..dea4e24d070 --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/McpEndpointAnnotation.cs @@ -0,0 +1,65 @@ +// 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; + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Annotation that marks a resource as exposing MCP endpoints. +/// +internal sealed class McpEndpointAnnotation : IResourceAnnotation +{ + public McpEndpointAnnotation(string transport, EndpointReference endpointReference, string? authToken = null, string? @namespace = null) + { + ArgumentNullException.ThrowIfNull(endpointReference); + + Transport = transport ?? throw new ArgumentNullException(nameof(transport)); + EndpointReference = endpointReference; + AuthToken = authToken; + Namespace = @namespace; + } + + public McpEndpointAnnotation(McpEndpointDefinition endpoint) + { + ArgumentNullException.ThrowIfNull(endpoint); + + Transport = endpoint.Transport; + StaticUri = endpoint.Uri; + AuthToken = endpoint.AuthToken; + Namespace = endpoint.Namespace; + } + + /// + /// The transport exposed by the MCP server (e.g. http, websocket, stdio). + /// + public string Transport { get; } + + /// + /// Optional bearer token used for authentication. + /// + public string? AuthToken { get; } + + /// + /// Optional namespace for tools coming from this endpoint. + /// + public string? Namespace { get; } + + /// + /// The endpoint reference (resolved at runtime) if available. + /// + public EndpointReference? EndpointReference { get; } + + /// + /// The static URI if the endpoint is already known. + /// + public Uri? StaticUri { get; } + + public static string Serialize(IEnumerable endpoints) + { + return JsonSerializer.Serialize(endpoints, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + } +} diff --git a/src/Aspire.Hosting/ApplicationModel/McpEndpointDefinition.cs b/src/Aspire.Hosting/ApplicationModel/McpEndpointDefinition.cs new file mode 100644 index 00000000000..f918df5b3dd --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/McpEndpointDefinition.cs @@ -0,0 +1,48 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Represents an MCP endpoint that can be exposed for a resource. +/// +public sealed record McpEndpointDefinition +{ + /// + /// Creates a new instance. + /// + /// The endpoint URI. + /// The transport used by the MCP server (e.g. http, websocket, stdio). + /// Optional bearer token used to authenticate against the MCP server. + /// Optional namespace to group tools from this endpoint. + [SetsRequiredMembers] + public McpEndpointDefinition(Uri uri, string transport, string? authToken = null, string? @namespace = null) + { + Uri = uri ?? throw new ArgumentNullException(nameof(uri)); + Transport = transport ?? throw new ArgumentNullException(nameof(transport)); + AuthToken = authToken; + Namespace = @namespace; + } + + /// + /// The MCP endpoint URI (e.g. https://redis-mcp:4000/mcp). + /// + public required Uri Uri { get; init; } + + /// + /// The transport used by the MCP server (e.g. http, websocket, stdio). + /// + public required string Transport { get; init; } + + /// + /// Optional bearer token used to authenticate against the MCP server. + /// + public string? AuthToken { get; init; } + + /// + /// Optional namespace to group tools from this endpoint. + /// + public string? Namespace { get; init; } +} diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs b/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs index 7621c57919d..3e6c1dca416 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs @@ -614,6 +614,66 @@ public Task PublishUpdateAsync(IResource resource, string resourceId, Func(out var mcpAnnotations)) + { + _logger.LogDebug("Resource {ResourceName} has {Count} MCP endpoint annotations.", resource.Name, mcpAnnotations.Count()); + + var resolvedEndpoints = new List(); + + // Resolve endpoints or static urls, each ultimately materialized as an McpEndpointDefinition + foreach (var annotation in mcpAnnotations) + { + Uri? uri = null; + try + { + if (annotation.EndpointReference is not null) + { + _logger.LogDebug("Resource {ResourceName} has MCP endpoint reference to {EndpointResource}/{EndpointName}, IsAllocated: {IsAllocated}", + resource.Name, + annotation.EndpointReference.Resource.Name, + annotation.EndpointReference.EndpointName, + annotation.EndpointReference.IsAllocated); + + if (annotation.EndpointReference.IsAllocated) + { + uri = new Uri(annotation.EndpointReference.Url); + _logger.LogDebug("Resource {ResourceName} MCP endpoint resolved to {Uri}", resource.Name, uri); + } + } + else if (annotation.StaticUri is not null) + { + uri = annotation.StaticUri; + } + } + catch + { + // Ignore resolution failures, we'll try again on the next update. + } + + if (uri is null) + { + continue; + } + + resolvedEndpoints.Add(new McpEndpointDefinition(uri, annotation.Transport, annotation.AuthToken, annotation.Namespace)); + } + + if (resolvedEndpoints.Count > 0) + { + newState = newState with + { + Properties = newState.Properties.SetResourceProperty(KnownProperties.Resource.McpEndpoints, McpEndpointAnnotation.Serialize(resolvedEndpoints), IsSensitive: true) + }; + } + else if (newState.Properties.Any(p => string.Equals(p.Name, KnownProperties.Resource.McpEndpoints, StringComparisons.ResourcePropertyName))) + { + newState = newState with + { + Properties = newState.Properties.SetResourceProperty(KnownProperties.Resource.McpEndpoints, string.Empty, IsSensitive: true) + }; + } + } + notificationState.LastSnapshot = newState; OnResourceUpdated?.Invoke(new ResourceEvent(resource, resourceId, newState)); diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index 22601367d92..e6c7929a969 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -3218,5 +3218,39 @@ public static IResourceBuilder WithRemoteImageTag( { context.Options.RemoteImageTag = remoteImageTag; }); + } + + /// + /// Registers an MCP endpoint for the resource so Aspire MCP can proxy its tools. + /// + /// Type of resource. + /// The resource builder. + /// The MCP endpoint definition. + /// The . + public static IResourceBuilder WithMcpEndpoint(this IResourceBuilder builder, McpEndpointDefinition endpoint) where T : IResource + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(endpoint); + + return builder.WithAnnotation(new McpEndpointAnnotation(endpoint)); + } + + /// + /// Registers an MCP endpoint for the resource using an that is resolved at runtime. + /// + /// Type of resource. + /// The resource builder. + /// Endpoint reference pointing to the MCP server. + /// Transport used by the MCP server (e.g. http, websocket, stdio). + /// Optional bearer token used to authenticate against the MCP server. + /// Optional namespace to group tools from this endpoint. + /// The . + public static IResourceBuilder WithMcpEndpoint(this IResourceBuilder builder, EndpointReference endpointReference, string transport, string? authToken = null, string? @namespace = null) where T : IResource + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(endpointReference); + ArgumentNullException.ThrowIfNull(transport); + + return builder.WithAnnotation(new McpEndpointAnnotation(transport, endpointReference, authToken, @namespace)); } } diff --git a/src/Shared/Model/KnownProperties.cs b/src/Shared/Model/KnownProperties.cs index 769cef8f6c0..a4f88a1dbf9 100644 --- a/src/Shared/Model/KnownProperties.cs +++ b/src/Shared/Model/KnownProperties.cs @@ -30,6 +30,7 @@ public static class Resource public const string AppArgs = "resource.appArgs"; public const string AppArgsSensitivity = "resource.appArgsSensitivity"; public const string ExcludeFromMcp = "resource.excludeFromMcp"; + public const string McpEndpoints = "resource.mcpEndpoints"; } public static class Container diff --git a/tests/Aspire.Cli.Tests/Mcp/MockPackagingService.cs b/tests/Aspire.Cli.Tests/Mcp/MockPackagingService.cs index 3442bb05585..43181957dda 100644 --- a/tests/Aspire.Cli.Tests/Mcp/MockPackagingService.cs +++ b/tests/Aspire.Cli.Tests/Mcp/MockPackagingService.cs @@ -69,6 +69,10 @@ internal sealed class MockAuxiliaryBackchannelMonitor : IAuxiliaryBackchannelMon public AppHostAuxiliaryBackchannel? SelectedConnection => null; +#pragma warning disable CS0067 // Event is never used + public event Action? SelectedAppHostChanged; +#pragma warning restore CS0067 + public IReadOnlyList GetConnectionsForWorkingDirectory(DirectoryInfo workingDirectory) { // Return empty list by default (no in-scope AppHosts) diff --git a/tests/Aspire.Cli.Tests/TestServices/TestAuxiliaryBackchannelMonitor.cs b/tests/Aspire.Cli.Tests/TestServices/TestAuxiliaryBackchannelMonitor.cs index 2021b1ff2f7..297724b5cbc 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestAuxiliaryBackchannelMonitor.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestAuxiliaryBackchannelMonitor.cs @@ -8,10 +8,24 @@ namespace Aspire.Cli.Tests.TestServices; internal sealed class TestAuxiliaryBackchannelMonitor : IAuxiliaryBackchannelMonitor { private readonly Dictionary _connections = new(); + private string? _selectedAppHostPath; public IReadOnlyDictionary Connections => _connections; - public string? SelectedAppHostPath { get; set; } + public string? SelectedAppHostPath + { + get => _selectedAppHostPath; + set + { + if (_selectedAppHostPath != value) + { + _selectedAppHostPath = value; + SelectedAppHostChanged?.Invoke(); + } + } + } + + public event Action? SelectedAppHostChanged; public AppHostAuxiliaryBackchannel? SelectedConnection { diff --git a/tests/Aspire.Dashboard.Tests/Integration/McpServiceTests.cs b/tests/Aspire.Dashboard.Tests/Integration/McpServiceTests.cs index eac028604ff..b2d800cb6b3 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/McpServiceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/McpServiceTests.cs @@ -249,6 +249,12 @@ public async Task CallService_McpTool_TelemetryRecorded() await using var app = IntegrationTestHelpers.CreateDashboardWebApplication( _testOutputHelper, + additionalConfiguration: config => + { + // Configure resource service URL so that the telemetry filter gets registered + config[DashboardConfigNames.ResourceServiceUrlName.ConfigKey] = "http://localhost:5000"; + config[DashboardConfigNames.ResourceServiceClientAuthModeName.ConfigKey] = nameof(ResourceClientAuthMode.Unsecured); + }, preConfigureBuilder: builder => { // Replace the telemetry sender with our test version