|
// 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;
using System.Runtime.CompilerServices;
using System.Text.Json;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Dashboard;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
namespace Aspire.Hosting.Backchannel;
/// <summary>
/// RPC target for the auxiliary backchannel that provides MCP-related operations.
/// </summary>
internal sealed class AuxiliaryBackchannelRpcTarget(
ILogger<AuxiliaryBackchannelRpcTarget> logger,
IServiceProvider serviceProvider)
{
private const string McpEndpointName = "mcp";
private static readonly TimeSpan s_mcpDiscoveryTimeout = TimeSpan.FromSeconds(5);
#region V2 API Methods
/// <summary>
/// Gets the capabilities supported by this auxiliary backchannel.
/// </summary>
/// <param name="request">The request (currently unused, for future expansion).</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The capabilities response containing supported versions.</returns>
#pragma warning disable CA1822 // Mark members as static - RPC methods cannot be static
public Task<GetCapabilitiesResponse> GetCapabilitiesAsync(GetCapabilitiesRequest? request = null, CancellationToken cancellationToken = default)
#pragma warning restore CA1822
{
_ = request;
_ = cancellationToken;
return Task.FromResult(new GetCapabilitiesResponse
{
Capabilities = [AuxiliaryBackchannelCapabilities.V1, AuxiliaryBackchannelCapabilities.V2]
});
}
/// <summary>
/// Gets AppHost information (v2 API with request object).
/// </summary>
/// <param name="request">The request (currently unused, for future expansion).</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The AppHost information response.</returns>
public async Task<GetAppHostInfoResponse> GetAppHostInfoAsync(GetAppHostInfoRequest? request = null, CancellationToken cancellationToken = default)
{
_ = request;
var legacyInfo = await GetAppHostInformationAsync(cancellationToken).ConfigureAwait(false);
return new GetAppHostInfoResponse
{
Pid = legacyInfo.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture),
AspireHostVersion = typeof(AuxiliaryBackchannelRpcTarget).Assembly.GetName().Version?.ToString() ?? "unknown",
AppHostPath = legacyInfo.AppHostPath,
CliProcessId = legacyInfo.CliProcessId,
StartedAt = legacyInfo.StartedAt
};
}
/// <summary>
/// Gets Dashboard information (v2 API with request object).
/// </summary>
/// <param name="request">The request (currently unused, for future expansion).</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The Dashboard information response.</returns>
public async Task<GetDashboardInfoResponse> GetDashboardInfoAsync(GetDashboardInfoRequest? request = null, CancellationToken cancellationToken = default)
{
_ = request;
var info = await DashboardUrlsHelper.GetDashboardConnectionInfoAsync(serviceProvider, logger, cancellationToken).ConfigureAwait(false);
var urls = new List<string>(2);
if (!string.IsNullOrEmpty(info.BaseUrlWithLoginToken))
{
urls.Add(info.BaseUrlWithLoginToken);
}
if (!string.IsNullOrEmpty(info.CodespacesUrlWithLoginToken))
{
urls.Add(info.CodespacesUrlWithLoginToken);
}
return new GetDashboardInfoResponse
{
McpBaseUrl = info.McpBaseUrl,
McpApiToken = info.McpApiToken,
ApiBaseUrl = info.ApiBaseUrl,
ApiToken = info.ApiToken,
DashboardUrls = urls.ToArray(),
IsHealthy = info.IsHealthy
};
}
/// <summary>
/// Gets resource snapshots (v2 API with request object).
/// </summary>
/// <param name="request">The request with optional filtering.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The resources response containing snapshots.</returns>
public async Task<GetResourcesResponse> GetResourcesAsync(GetResourcesRequest? request = null, CancellationToken cancellationToken = default)
{
var snapshots = await GetResourceSnapshotsAsync(cancellationToken).ConfigureAwait(false);
// Apply filter if specified
if (!string.IsNullOrEmpty(request?.Filter))
{
var filter = request.Filter;
snapshots = snapshots.Where(s => s.Name.Contains(filter, StringComparison.OrdinalIgnoreCase)).ToList();
}
return new GetResourcesResponse
{
Resources = snapshots.ToArray()
};
}
/// <summary>
/// Watches for resource changes (v2 API with request object).
/// </summary>
/// <param name="request">The request with optional filtering.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>An async enumerable of resource snapshots as they change.</returns>
public async IAsyncEnumerable<ResourceSnapshot> WatchResourcesAsync(WatchResourcesRequest? request = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var filter = request?.Filter;
await foreach (var snapshot in WatchResourceSnapshotsAsync(cancellationToken).ConfigureAwait(false))
{
// Apply filter if specified
if (!string.IsNullOrEmpty(filter) && !snapshot.Name.Contains(filter, StringComparison.OrdinalIgnoreCase))
{
continue;
}
yield return snapshot;
}
}
/// <summary>
/// Gets console logs (v2 API with request object).
/// </summary>
/// <param name="request">The request specifying resource and options.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>An async enumerable of log lines.</returns>
public IAsyncEnumerable<ResourceLogLine> GetConsoleLogsAsync(GetConsoleLogsRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
return GetResourceLogsAsync(request.ResourceName, request.Follow, cancellationToken);
}
/// <summary>
/// Calls an MCP tool on a resource (v2 API with request object).
/// </summary>
/// <param name="request">The request specifying resource, tool, and arguments.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The tool call response.</returns>
public async Task<CallMcpToolResponse> CallMcpToolAsync(CallMcpToolRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
// Convert JsonElement arguments to Dictionary<string, object?> with proper value conversion
var arguments = new Dictionary<string, object?>();
if (request.Arguments is JsonElement argsElement && argsElement.ValueKind == JsonValueKind.Object)
{
foreach (var prop in argsElement.EnumerateObject())
{
arguments[prop.Name] = ConvertJsonElementToObject(prop.Value);
}
}
var result = await CallResourceMcpToolAsync(request.ResourceName, request.ToolName, arguments, cancellationToken).ConfigureAwait(false);
return new CallMcpToolResponse
{
IsError = result.IsError ?? false,
Content = result.Content.Select(c => new McpToolContentItem
{
Type = c.Type,
Text = (c as ModelContextProtocol.Protocol.TextContentBlock)?.Text
}).ToArray()
};
}
/// <summary>
/// Stops the AppHost (v2 API with request object).
/// </summary>
/// <param name="request">The request with optional exit code.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The stop response.</returns>
public async Task<StopAppHostResponse> StopAsync(StopAppHostRequest? request = null, CancellationToken cancellationToken = default)
{
_ = request; // Exit code not yet used, but available for future expansion
await StopAppHostAsync(cancellationToken).ConfigureAwait(false);
return new StopAppHostResponse();
}
/// <summary>
/// Executes a command on a resource.
/// </summary>
/// <param name="request">The request containing resource name and command name.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The response indicating success or failure.</returns>
public async Task<ExecuteResourceCommandResponse> ExecuteResourceCommandAsync(ExecuteResourceCommandRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var resourceCommandService = serviceProvider.GetRequiredService<ResourceCommandService>();
var result = await resourceCommandService.ExecuteCommandAsync(request.ResourceName, request.CommandName, cancellationToken).ConfigureAwait(false);
return new ExecuteResourceCommandResponse
{
Success = result.Success,
Canceled = result.Canceled,
ErrorMessage = result.ErrorMessage
};
}
#endregion
#region V1 API Methods (Legacy - Keep for backward compatibility)
/// <summary>
/// Gets information about the AppHost for the MCP server.
/// </summary>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The AppHost information including the fully qualified path and process ID.</returns>
/// <exception cref="InvalidOperationException">Thrown when AppHost information is not available.</exception>
public Task<AppHostInformation> GetAppHostInformationAsync(CancellationToken cancellationToken = default)
{
// The cancellationToken parameter is not currently used, but is retained for API consistency and potential future support for cancellation.
_ = cancellationToken;
var configuration = serviceProvider.GetService<IConfiguration>();
if (configuration is null)
{
logger.LogError("Configuration not found.");
throw new InvalidOperationException("Configuration not found.");
}
// First try to get the file path (with extension), otherwise fall back to the path (without extension)
var appHostPath = configuration["AppHost:FilePath"] ?? configuration["AppHost:Path"];
if (string.IsNullOrEmpty(appHostPath))
{
logger.LogError("AppHost path not found in configuration.");
throw new InvalidOperationException("AppHost path not found in configuration.");
}
// Get the CLI process ID if the AppHost was launched via the CLI
int? cliProcessId = null;
var cliPidString = configuration[KnownConfigNames.CliProcessId];
if (!string.IsNullOrEmpty(cliPidString) && int.TryParse(cliPidString, out var parsedCliPid))
{
cliProcessId = parsedCliPid;
}
return Task.FromResult(new AppHostInformation
{
AppHostPath = appHostPath,
ProcessId = Environment.ProcessId,
CliProcessId = cliProcessId,
StartedAt = new DateTimeOffset(Process.GetCurrentProcess().StartTime)
});
}
/// <summary>
/// Gets the Dashboard MCP connection information including endpoint URL and API token.
/// </summary>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The MCP connection information, or null if the dashboard is not part of the application model.</returns>
public async Task<DashboardMcpConnectionInfo?> GetDashboardMcpConnectionInfoAsync(CancellationToken cancellationToken = default)
{
var appModel = serviceProvider.GetService<DistributedApplicationModel>();
if (appModel is null)
{
logger.LogWarning("Application model not found.");
return null;
}
// Find the dashboard resource
if (appModel.Resources.SingleOrDefault(r => StringComparers.ResourceName.Equals(r.Name, KnownResourceNames.AspireDashboard)) is not IResourceWithEndpoints dashboardResource)
{
logger.LogDebug("Dashboard resource not found in application model.");
return null;
}
var mcpEndpoint = dashboardResource.GetEndpoint(McpEndpointName);
if (!mcpEndpoint.Exists)
{
logger.LogWarning("Dashboard MCP endpoint not found or not allocated.");
return null;
}
var endpointUrl = await mcpEndpoint.GetValueAsync(cancellationToken).ConfigureAwait(false);
if (string.IsNullOrEmpty(endpointUrl))
{
logger.LogWarning("Dashboard MCP endpoint URL is not allocated.");
return null;
}
// Get the API key from dashboard options
var dashboardOptions = serviceProvider.GetService<IOptions<DashboardOptions>>();
var mcpApiKey = dashboardOptions?.Value.McpApiKey;
if (string.IsNullOrEmpty(mcpApiKey))
{
logger.LogWarning("Dashboard MCP API key is not available.");
return null;
}
return new DashboardMcpConnectionInfo
{
EndpointUrl = $"{endpointUrl}/mcp",
ApiToken = mcpApiKey
};
}
/// <summary>
/// Gets the Dashboard URLs including the login token.
/// </summary>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The Dashboard URLs state including health and login URLs.</returns>
public async Task<DashboardUrlsState> GetDashboardUrlsAsync(CancellationToken cancellationToken = default)
{
logger.LogInformation("GetDashboardUrlsAsync called on auxiliary backchannel");
return await DashboardUrlsHelper.GetDashboardUrlsAsync(serviceProvider, logger, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Gets the current resource snapshots for all resources.
/// </summary>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A list of resource snapshots.</returns>
public async Task<List<ResourceSnapshot>> GetResourceSnapshotsAsync(CancellationToken cancellationToken = default)
{
var appModel = serviceProvider.GetService<DistributedApplicationModel>();
var notificationService = serviceProvider.GetRequiredService<ResourceNotificationService>();
var results = new List<ResourceSnapshot>();
if (appModel is null)
{
return results;
}
// Get current state for each resource directly using TryGetCurrentState
foreach (var resource in appModel.Resources)
{
// Skip the dashboard resource
if (StringComparers.ResourceName.Equals(resource.Name, KnownResourceNames.AspireDashboard))
{
continue;
}
foreach (var instanceName in resource.GetResolvedResourceNames())
{
await AddResult(instanceName).ConfigureAwait(false);
}
}
return results;
async Task AddResult(string resourceName)
{
if (notificationService.TryGetCurrentState(resourceName, out var resourceEvent))
{
var snapshot = await CreateResourceSnapshotFromEventAsync(resourceEvent, cancellationToken).ConfigureAwait(false);
if (snapshot is not null)
{
results.Add(snapshot);
}
}
}
}
/// <summary>
/// Watches for resource snapshot changes and streams them to the client.
/// </summary>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>An async enumerable of resource snapshots as they change.</returns>
public async IAsyncEnumerable<ResourceSnapshot> WatchResourceSnapshotsAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var notificationService = serviceProvider.GetRequiredService<ResourceNotificationService>();
var resourceEvents = notificationService.WatchAsync(cancellationToken);
await foreach (var resourceEvent in resourceEvents.WithCancellation(cancellationToken).ConfigureAwait(false))
{
// Skip the dashboard resource
if (StringComparers.ResourceName.Equals(resourceEvent.Resource.Name, KnownResourceNames.AspireDashboard))
{
continue;
}
var snapshot = await CreateResourceSnapshotFromEventAsync(resourceEvent, cancellationToken).ConfigureAwait(false);
if (snapshot is not null)
{
yield return snapshot;
}
}
}
private async Task<ResourceSnapshot?> CreateResourceSnapshotFromEventAsync(
ResourceEvent resourceEvent,
CancellationToken cancellationToken)
{
var resource = resourceEvent.Resource;
var snapshot = resourceEvent.Snapshot;
// Get MCP server info if available
ResourceSnapshotMcpServer? mcpServer = null;
if (resource is IResourceWithEndpoints resourceWithEndpoints &&
resourceWithEndpoints.TryGetLastAnnotation<McpServerEndpointAnnotation>(out var mcpAnnotation))
{
var endpointUri = await mcpAnnotation.EndpointUrlResolver(resourceWithEndpoints, cancellationToken).ConfigureAwait(false);
if (endpointUri is not null)
{
var tools = await TryListToolsAsync(endpointUri, cancellationToken).ConfigureAwait(false);
if (tools is not null)
{
mcpServer = new ResourceSnapshotMcpServer
{
EndpointUrl = endpointUri.ToString(),
Tools = tools
};
}
}
}
// Build URLs
var urls = snapshot.Urls
.Where(u => !u.IsInactive && !string.IsNullOrEmpty(u.Url))
.Select(u => new ResourceSnapshotUrl
{
Name = u.Name ?? "default",
Url = u.Url,
IsInternal = u.IsInternal,
DisplayProperties = new ResourceSnapshotUrlDisplayProperties
{
DisplayName = string.IsNullOrEmpty(u.DisplayProperties.DisplayName) ? null : u.DisplayProperties.DisplayName,
SortOrder = u.DisplayProperties.SortOrder
}
})
.ToArray();
// Build relationships
var relationships = snapshot.Relationships
.Select(r => new ResourceSnapshotRelationship
{
ResourceName = r.ResourceName,
Type = r.Type
})
.ToArray();
// Build health reports
var healthReports = snapshot.HealthReports
.Select(h => new ResourceSnapshotHealthReport
{
Name = h.Name,
Status = h.Status?.ToString(),
Description = h.Description,
ExceptionText = h.ExceptionText
})
.ToArray();
// Build volumes
var volumes = snapshot.Volumes
.Select(v => new ResourceSnapshotVolume
{
Source = v.Source,
Target = v.Target,
MountType = v.MountType,
IsReadOnly = v.IsReadOnly
})
.ToArray();
// Build environment variables
var environmentVariables = snapshot.EnvironmentVariables
.Select(e => new ResourceSnapshotEnvironmentVariable
{
Name = e.Name,
Value = e.Value,
IsFromSpec = e.IsFromSpec
})
.ToArray();
// Build properties dictionary from ResourcePropertySnapshot
// Redact sensitive property values to avoid leaking secrets
var properties = new Dictionary<string, string?>();
foreach (var prop in snapshot.Properties)
{
// Redact sensitive property values
if (prop.IsSensitive)
{
properties[prop.Name] = null;
continue;
}
// Convert value to string representation
var stringValue = prop.Value switch
{
null => null,
string s => s,
IEnumerable<object> enumerable => string.Join(", ", enumerable),
System.Collections.IEnumerable enumerable => string.Join(", ", enumerable.Cast<object>()),
_ => prop.Value.ToString()
};
properties[prop.Name] = stringValue;
}
// Build commands
var commands = snapshot.Commands
.Select(c => new ResourceSnapshotCommand
{
Name = c.Name,
DisplayName = c.DisplayName,
Description = c.DisplayDescription,
State = c.State.ToString()
})
.ToArray();
return new ResourceSnapshot
{
Name = resourceEvent.ResourceId,
DisplayName = resource.Name,
ResourceType = snapshot.ResourceType,
State = snapshot.State?.Text,
StateStyle = snapshot.State?.Style,
HealthStatus = snapshot.HealthStatus?.ToString(),
ExitCode = snapshot.ExitCode,
CreatedAt = snapshot.CreationTimeStamp,
StartedAt = snapshot.StartTimeStamp,
StoppedAt = snapshot.StopTimeStamp,
Urls = urls,
Relationships = relationships,
HealthReports = healthReports,
Volumes = volumes,
EnvironmentVariables = environmentVariables,
Properties = properties,
McpServer = mcpServer,
Commands = commands
};
}
/// <summary>
/// Watches for resource log output and streams log lines to the client.
/// </summary>
/// <param name="resourceName">Optional resource name. If null, streams logs from all resources (only valid with follow=true).</param>
/// <param name="follow">If true, continuously streams logs. If false, returns existing logs and completes.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>An async enumerable of log lines.</returns>
public async IAsyncEnumerable<ResourceLogLine> GetResourceLogsAsync(
string? resourceName = null,
bool follow = false,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var resourceLoggerService = serviceProvider.GetRequiredService<ResourceLoggerService>();
var appModel = serviceProvider.GetService<DistributedApplicationModel>();
if (resourceName is not null)
{
// Look up the resource from the app model to get resolved DCP resource names
var resource = appModel?.Resources.FirstOrDefault(r => StringComparers.ResourceName.Equals(r.Name, resourceName));
// Get the resolved resource names (DCP names for replicas)
var resolvedNames = resource?.GetResolvedResourceNames() ?? [resourceName];
var hasReplicas = resolvedNames.Length > 1;
if (hasReplicas && follow)
{
// For replicas in follow mode, watch each replica individually to preserve source
var channel = System.Threading.Channels.Channel.CreateUnbounded<ResourceLogLine>();
var watchTasks = new List<Task>();
foreach (var dcpName in resolvedNames)
{
var name = dcpName;
var task = Task.Run(async () =>
{
try
{
await foreach (var batch in resourceLoggerService.WatchAsync(name).WithCancellation(cancellationToken).ConfigureAwait(false))
{
foreach (var logLine in batch)
{
await channel.Writer.WriteAsync(new ResourceLogLine
{
ResourceName = name,
LineNumber = logLine.LineNumber,
Content = logLine.Content,
IsError = logLine.IsErrorMessage
}, cancellationToken).ConfigureAwait(false);
}
}
}
catch (OperationCanceledException)
{
// Expected when cancelled
}
catch (Exception ex)
{
logger.LogDebug(ex, "Error watching logs for resource {ResourceName}", name);
}
}, cancellationToken);
watchTasks.Add(task);
}
_ = Task.WhenAll(watchTasks).ContinueWith(_ => channel.Writer.Complete(), CancellationToken.None, TaskContinuationOptions.None, TaskScheduler.Default);
await foreach (var logLine in channel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
{
yield return logLine;
}
}
else if (hasReplicas)
{
// For replicas in snapshot mode, get logs from each replica individually
foreach (var dcpName in resolvedNames)
{
await foreach (var batch in resourceLoggerService.GetAllAsync(dcpName).WithCancellation(cancellationToken).ConfigureAwait(false))
{
foreach (var logLine in batch)
{
yield return new ResourceLogLine
{
ResourceName = dcpName,
LineNumber = logLine.LineNumber,
Content = logLine.Content,
IsError = logLine.IsErrorMessage
};
}
}
}
}
else
{
// Single resource (no replicas) - use original behavior
var logStream = follow
? resourceLoggerService.WatchAsync(resolvedNames[0])
: resourceLoggerService.GetAllAsync(resolvedNames[0]);
await foreach (var batch in logStream.WithCancellation(cancellationToken).ConfigureAwait(false))
{
foreach (var logLine in batch)
{
yield return new ResourceLogLine
{
ResourceName = resourceName, // Use app-model name for single resources
LineNumber = logLine.LineNumber,
Content = logLine.Content,
IsError = logLine.IsErrorMessage
};
}
}
}
}
else if (follow && appModel is not null)
{
// Stream logs from all resources (only valid with follow=true)
// Create a merged stream from all resources
var channel = System.Threading.Channels.Channel.CreateUnbounded<ResourceLogLine>();
// Start watching all resources in parallel, using DCP names for replicas
var watchTasks = new List<Task>();
foreach (var resource in appModel.Resources)
{
// Skip the dashboard
if (StringComparers.ResourceName.Equals(resource.Name, KnownResourceNames.AspireDashboard))
{
continue;
}
var resolvedNames = resource.GetResolvedResourceNames();
var hasReplicas = resolvedNames.Length > 1;
foreach (var dcpName in resolvedNames)
{
// Use DCP name for replicas, app-model name for single resources
var displayName = hasReplicas ? dcpName : resource.Name;
var name = dcpName;
var task = Task.Run(async () =>
{
try
{
await foreach (var batch in resourceLoggerService.WatchAsync(name).WithCancellation(cancellationToken).ConfigureAwait(false))
{
foreach (var logLine in batch)
{
await channel.Writer.WriteAsync(new ResourceLogLine
{
ResourceName = displayName,
LineNumber = logLine.LineNumber,
Content = logLine.Content,
IsError = logLine.IsErrorMessage
}, cancellationToken).ConfigureAwait(false);
}
}
}
catch (OperationCanceledException)
{
// Expected when cancelled
}
catch (Exception ex)
{
logger.LogDebug(ex, "Error watching logs for resource {ResourceName}", name);
}
}, cancellationToken);
watchTasks.Add(task);
}
}
// Complete the channel when all watch tasks complete
_ = Task.WhenAll(watchTasks).ContinueWith(_ => channel.Writer.Complete(), CancellationToken.None, TaskContinuationOptions.None, TaskScheduler.Default);
// Yield log lines as they arrive
await foreach (var logLine in channel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
{
yield return logLine;
}
}
}
/// <summary>
/// Invokes a tool on the MCP server exposed by a resource annotated with <see cref="McpServerEndpointAnnotation"/>.
/// </summary>
/// <param name="resourceName">The resource name.</param>
/// <param name="toolName">The tool name to invoke.</param>
/// <param name="arguments">Tool arguments.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A JSON representation of the MCP <see cref="CallToolResult"/>.</returns>
public async Task<CallToolResult> CallResourceMcpToolAsync(
string resourceName,
string toolName,
Dictionary<string, object?> arguments,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(resourceName);
ArgumentException.ThrowIfNullOrWhiteSpace(toolName);
var appModel = serviceProvider.GetService<DistributedApplicationModel>();
if (appModel is null)
{
throw new InvalidOperationException("Application model not found.");
}
var resource = appModel.Resources
.OfType<IResourceWithEndpoints>()
.FirstOrDefault(r => string.Equals(r.Name, resourceName, StringComparisons.ResourceName));
if (resource is null)
{
throw new InvalidOperationException($"Resource '{resourceName}' not found.");
}
if (!resource.TryGetLastAnnotation<McpServerEndpointAnnotation>(out var annotation))
{
throw new InvalidOperationException($"Resource '{resourceName}' does not have an MCP endpoint annotation.");
}
var endpointUri = await annotation.EndpointUrlResolver(resource, cancellationToken).ConfigureAwait(false);
if (endpointUri is null)
{
throw new InvalidOperationException($"MCP endpoint for resource '{resourceName}' is not available.");
}
var transport = CreateHttpClientTransport(endpointUri);
McpClient? mcpClient = null;
try
{
mcpClient = await McpClient.CreateAsync(transport, cancellationToken: cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to create MCP client.");
if (logger.IsEnabled(LogLevel.Debug))
{
logger.LogDebug("Invoking tool {Name} with arguments {Arguments}", toolName, JsonSerializer.Serialize(arguments));
}
var result = await mcpClient.CallToolAsync(toolName, arguments, cancellationToken: cancellationToken).ConfigureAwait(false);
if (logger.IsEnabled(LogLevel.Debug))
{
logger.LogDebug("Result: {Result}", JsonSerializer.Serialize(result));
}
return result;
}
catch (Exception ex)
{
logger.LogError(ex, "Error invoking tool {ToolName} on resource {ResourceName}", toolName, resourceName);
throw;
}
finally
{
if (mcpClient is not null)
{
await mcpClient.DisposeAsync().ConfigureAwait(false);
}
await transport.DisposeAsync().ConfigureAwait(false);
}
}
/// <summary>
/// Requests the AppHost to stop gracefully. The stop is initiated asynchronously in the background.
/// </summary>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>
/// A task that completes immediately after initiating the stop request. The actual stop occurs asynchronously.
/// </returns>
public Task StopAppHostAsync(CancellationToken cancellationToken = default)
{
_ = cancellationToken; // Unused but kept for API consistency
logger.LogInformation("Received request to stop AppHost");
// Start a background task to delay the stop by 500ms to allow the RPC response to be sent
_ = Task.Run(async () =>
{
try
{
await Task.Delay(500, CancellationToken.None).ConfigureAwait(false);
// Cancel inflight RPC calls in AppHostRpcTarget before stopping
var appHostRpcTarget = serviceProvider.GetService<AppHostRpcTarget>();
appHostRpcTarget?.CancelInflightRpcCalls();
var lifetime = serviceProvider.GetService<IHostApplicationLifetime>();
if (lifetime is not null)
{
logger.LogInformation("Stopping AppHost application");
lifetime.StopApplication();
}
else
{
logger.LogWarning("IHostApplicationLifetime not found, cannot stop AppHost");
}
}
catch (Exception ex)
{
logger.LogError(ex, "Error while stopping AppHost");
}
}, CancellationToken.None);
return Task.CompletedTask;
}
private async Task<Tool[]?> TryListToolsAsync(Uri endpointUri, CancellationToken cancellationToken)
{
var transport = CreateHttpClientTransport(endpointUri);
using var timeoutCts = new CancellationTokenSource(s_mcpDiscoveryTimeout);
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
try
{
var mcpClient = await McpClient.CreateAsync(transport, cancellationToken: linked.Token).ConfigureAwait(false);
try
{
var toolsList = await mcpClient.ListToolsAsync(cancellationToken: linked.Token).ConfigureAwait(false);
return toolsList.Select(c => c.ProtocolTool).ToArray();
}
finally
{
await mcpClient.DisposeAsync().ConfigureAwait(false);
}
}
catch (Exception ex)
{
logger.LogDebug(ex, "Failed to list tools from MCP endpoint {EndpointUri}", endpointUri);
return null;
}
finally
{
await transport.DisposeAsync().ConfigureAwait(false);
}
}
private HttpClientTransport CreateHttpClientTransport(Uri endpointUri)
{
var httpClientFactory = serviceProvider.GetService<IHttpClientFactory>();
var httpClient = httpClientFactory?.CreateClient() ?? new HttpClient();
return new HttpClientTransport(
new HttpClientTransportOptions { Endpoint = endpointUri },
httpClient,
serviceProvider.GetRequiredService<ILoggerFactory>(),
ownsHttpClient: true);
}
#endregion
/// <summary>
/// Converts a JsonElement to its underlying CLR type for proper serialization.
/// </summary>
private static object? ConvertJsonElementToObject(JsonElement element)
{
return element.ValueKind switch
{
JsonValueKind.Null => null,
JsonValueKind.String => element.GetString(),
JsonValueKind.Number => ConvertJsonNumber(element),
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Array => element.EnumerateArray().Select(ConvertJsonElementToObject).ToArray(),
JsonValueKind.Object => element.EnumerateObject().ToDictionary(p => p.Name, p => ConvertJsonElementToObject(p.Value)),
_ => element.Clone()
};
}
private static object ConvertJsonNumber(JsonElement element)
{
// Try integer types first
if (element.TryGetInt32(out var i32))
{
return i32;
}
if (element.TryGetInt64(out var i64))
{
return i64;
}
// Fall back to double for floating point
return element.GetDouble();
}
}
|