mirror of https://github.com/abpframework/abp.git
committed by
GitHub
21 changed files with 1206 additions and 21 deletions
@ -0,0 +1,11 @@ |
|||||
|
using Volo.Abp.Cli.Commands; |
||||
|
|
||||
|
namespace Volo.Abp.Cli.Args; |
||||
|
|
||||
|
public static class CommandLineArgsExtensions |
||||
|
{ |
||||
|
public static bool IsMcpCommand(this CommandLineArgs args) |
||||
|
{ |
||||
|
return args.IsCommand(McpCommand.Name); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,197 @@ |
|||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Diagnostics; |
||||
|
using System.IO; |
||||
|
using System.Reflection; |
||||
|
using System.Text; |
||||
|
using System.Text.Json; |
||||
|
using System.Threading; |
||||
|
using System.Threading.Tasks; |
||||
|
using Microsoft.Extensions.Logging; |
||||
|
using Microsoft.Extensions.Logging.Abstractions; |
||||
|
using Volo.Abp.Cli.Args; |
||||
|
using Volo.Abp.Cli.Auth; |
||||
|
using Volo.Abp.Cli.Commands.Models; |
||||
|
using Volo.Abp.Cli.Commands.Services; |
||||
|
using Volo.Abp.Cli.Licensing; |
||||
|
using Volo.Abp.DependencyInjection; |
||||
|
using Volo.Abp.Internal.Telemetry; |
||||
|
using Volo.Abp.Internal.Telemetry.Constants; |
||||
|
|
||||
|
namespace Volo.Abp.Cli.Commands; |
||||
|
|
||||
|
public class McpCommand : IConsoleCommand, ITransientDependency |
||||
|
{ |
||||
|
private const string LogSource = nameof(McpCommand); |
||||
|
public const string Name = "mcp"; |
||||
|
|
||||
|
private readonly AuthService _authService; |
||||
|
private readonly IApiKeyService _apiKeyService; |
||||
|
private readonly McpServerService _mcpServerService; |
||||
|
private readonly McpHttpClientService _mcpHttpClient; |
||||
|
private readonly IMcpLogger _mcpLogger; |
||||
|
private readonly ITelemetryService _telemetryService; |
||||
|
|
||||
|
public ILogger<McpCommand> Logger { get; set; } |
||||
|
|
||||
|
public McpCommand( |
||||
|
IApiKeyService apiKeyService, |
||||
|
AuthService authService, |
||||
|
McpServerService mcpServerService, |
||||
|
McpHttpClientService mcpHttpClient, |
||||
|
IMcpLogger mcpLogger, |
||||
|
ITelemetryService telemetryService) |
||||
|
{ |
||||
|
_apiKeyService = apiKeyService; |
||||
|
_authService = authService; |
||||
|
_mcpServerService = mcpServerService; |
||||
|
_mcpHttpClient = mcpHttpClient; |
||||
|
_mcpLogger = mcpLogger; |
||||
|
_telemetryService = telemetryService; |
||||
|
Logger = NullLogger<McpCommand>.Instance; |
||||
|
} |
||||
|
|
||||
|
public async Task ExecuteAsync(CommandLineArgs commandLineArgs) |
||||
|
{ |
||||
|
await ValidateLicenseAsync(); |
||||
|
|
||||
|
var option = commandLineArgs.Target; |
||||
|
|
||||
|
if (!string.IsNullOrEmpty(option) && option.Equals("get-config", StringComparison.OrdinalIgnoreCase)) |
||||
|
{ |
||||
|
await PrintConfigurationAsync(); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
await using var _ = _telemetryService.TrackActivityAsync(ActivityNameConsts.AbpCliCommandsMcp); |
||||
|
|
||||
|
// Check server health before starting - fail if not reachable
|
||||
|
_mcpLogger.Info(LogSource, "Checking ABP.IO MCP Server connection..."); |
||||
|
var isHealthy = await _mcpHttpClient.CheckServerHealthAsync(); |
||||
|
|
||||
|
if (!isHealthy) |
||||
|
{ |
||||
|
throw new CliUsageException( |
||||
|
"Could not connect to ABP.IO MCP Server. " + |
||||
|
"The MCP server requires a connection to fetch tool definitions. " + |
||||
|
"Please check your internet connection and try again."); |
||||
|
} |
||||
|
|
||||
|
_mcpLogger.Info(LogSource, "Starting ABP MCP Server..."); |
||||
|
|
||||
|
var cts = new CancellationTokenSource(); |
||||
|
|
||||
|
ConsoleCancelEventHandler cancelHandler = (sender, e) => |
||||
|
{ |
||||
|
e.Cancel = true; |
||||
|
_mcpLogger.Info(LogSource, "Shutting down ABP MCP Server..."); |
||||
|
|
||||
|
try |
||||
|
{ |
||||
|
cts.Cancel(); |
||||
|
} |
||||
|
catch (ObjectDisposedException) |
||||
|
{ |
||||
|
// CTS already disposed
|
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
Console.CancelKeyPress += cancelHandler; |
||||
|
|
||||
|
try |
||||
|
{ |
||||
|
await _mcpServerService.RunAsync(cts.Token); |
||||
|
} |
||||
|
catch (OperationCanceledException) |
||||
|
{ |
||||
|
// Expected when Ctrl+C is pressed
|
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
_mcpLogger.Error(LogSource, "Error running MCP server", ex); |
||||
|
throw; |
||||
|
} |
||||
|
finally |
||||
|
{ |
||||
|
Console.CancelKeyPress -= cancelHandler; |
||||
|
cts.Dispose(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private async Task ValidateLicenseAsync() |
||||
|
{ |
||||
|
var loginInfo = await _authService.GetLoginInfoAsync(); |
||||
|
|
||||
|
if (string.IsNullOrEmpty(loginInfo?.Organization)) |
||||
|
{ |
||||
|
throw new CliUsageException("Please log in with your account!"); |
||||
|
} |
||||
|
|
||||
|
var licenseResult = await _apiKeyService.GetApiKeyOrNullAsync(); |
||||
|
|
||||
|
if (licenseResult == null || !licenseResult.HasActiveLicense) |
||||
|
{ |
||||
|
var errorMessage = licenseResult?.ErrorMessage ?? "No active license found."; |
||||
|
throw new CliUsageException(errorMessage); |
||||
|
} |
||||
|
|
||||
|
if (licenseResult.LicenseEndTime.HasValue && licenseResult.LicenseEndTime.Value < DateTime.UtcNow) |
||||
|
{ |
||||
|
throw new CliUsageException("Your license has expired. Please renew your license to use the MCP server."); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private Task PrintConfigurationAsync() |
||||
|
{ |
||||
|
var config = new McpClientConfiguration |
||||
|
{ |
||||
|
McpServers = new Dictionary<string, McpServerConfig> |
||||
|
{ |
||||
|
["abp"] = new McpServerConfig |
||||
|
{ |
||||
|
Command = "abp", |
||||
|
Args = new List<string> { "mcp" }, |
||||
|
Env = new Dictionary<string, string>() |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
var json = JsonSerializer.Serialize(config, new JsonSerializerOptions |
||||
|
{ |
||||
|
WriteIndented = true, |
||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase |
||||
|
}); |
||||
|
|
||||
|
Console.WriteLine(json); |
||||
|
|
||||
|
return Task.CompletedTask; |
||||
|
} |
||||
|
|
||||
|
public string GetUsageInfo() |
||||
|
{ |
||||
|
var sb = new StringBuilder(); |
||||
|
|
||||
|
sb.AppendLine(""); |
||||
|
sb.AppendLine("Usage:"); |
||||
|
sb.AppendLine(""); |
||||
|
sb.AppendLine(" abp mcp [options]"); |
||||
|
sb.AppendLine(""); |
||||
|
sb.AppendLine("Options:"); |
||||
|
sb.AppendLine(""); |
||||
|
sb.AppendLine("<no argument> (start the local MCP server)"); |
||||
|
sb.AppendLine("get-config (print MCP client configuration as JSON)"); |
||||
|
sb.AppendLine(""); |
||||
|
sb.AppendLine("Examples:"); |
||||
|
sb.AppendLine(""); |
||||
|
sb.AppendLine(" abp mcp"); |
||||
|
sb.AppendLine(" abp mcp get-config"); |
||||
|
sb.AppendLine(""); |
||||
|
|
||||
|
return sb.ToString(); |
||||
|
} |
||||
|
|
||||
|
public static string GetShortDescription() |
||||
|
{ |
||||
|
return "Runs the local MCP server and outputs client configuration for AI tool integration."; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,23 @@ |
|||||
|
using System.Collections.Generic; |
||||
|
using System.Text.Json.Serialization; |
||||
|
|
||||
|
namespace Volo.Abp.Cli.Commands.Models; |
||||
|
|
||||
|
public class McpClientConfiguration |
||||
|
{ |
||||
|
[JsonPropertyName("mcpServers")] |
||||
|
public Dictionary<string, McpServerConfig> McpServers { get; set; } = new(); |
||||
|
} |
||||
|
|
||||
|
public class McpServerConfig |
||||
|
{ |
||||
|
[JsonPropertyName("command")] |
||||
|
public string Command { get; set; } |
||||
|
|
||||
|
[JsonPropertyName("args")] |
||||
|
public List<string> Args { get; set; } = new(); |
||||
|
|
||||
|
[JsonPropertyName("env")] |
||||
|
public Dictionary<string, string> Env { get; set; } |
||||
|
} |
||||
|
|
||||
@ -0,0 +1,26 @@ |
|||||
|
using System.Collections.Generic; |
||||
|
using System.Text.Json; |
||||
|
|
||||
|
namespace Volo.Abp.Cli.Commands.Models; |
||||
|
|
||||
|
public class McpToolDefinition |
||||
|
{ |
||||
|
public string Name { get; set; } |
||||
|
public string Description { get; set; } |
||||
|
public McpToolInputSchema InputSchema { get; set; } |
||||
|
public JsonElement? OutputSchema { get; set; } |
||||
|
} |
||||
|
|
||||
|
public class McpToolInputSchema |
||||
|
{ |
||||
|
public string Type { get; set; } = "object"; |
||||
|
public Dictionary<string, McpToolProperty> Properties { get; set; } |
||||
|
public List<string> Required { get; set; } |
||||
|
} |
||||
|
|
||||
|
public class McpToolProperty |
||||
|
{ |
||||
|
public string Type { get; set; } |
||||
|
public string Description { get; set; } |
||||
|
} |
||||
|
|
||||
@ -0,0 +1,47 @@ |
|||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Text.Json; |
||||
|
using System.Threading; |
||||
|
using System.Threading.Tasks; |
||||
|
using ModelContextProtocol.Protocol; |
||||
|
using ModelContextProtocol.Server; |
||||
|
|
||||
|
namespace Volo.Abp.Cli.Commands.Services; |
||||
|
|
||||
|
internal class AbpMcpServerTool : McpServerTool |
||||
|
{ |
||||
|
private readonly string _name; |
||||
|
private readonly string _description; |
||||
|
private readonly JsonElement _inputSchema; |
||||
|
private readonly JsonElement? _outputSchema; |
||||
|
private readonly Func<RequestContext<CallToolRequestParams>, CancellationToken, ValueTask<CallToolResult>> _handler; |
||||
|
|
||||
|
public AbpMcpServerTool( |
||||
|
string name, |
||||
|
string description, |
||||
|
JsonElement inputSchema, |
||||
|
JsonElement? outputSchema, |
||||
|
Func<RequestContext<CallToolRequestParams>, CancellationToken, ValueTask<CallToolResult>> handler) |
||||
|
{ |
||||
|
_name = name; |
||||
|
_description = description; |
||||
|
_inputSchema = inputSchema; |
||||
|
_outputSchema = outputSchema; |
||||
|
_handler = handler; |
||||
|
} |
||||
|
|
||||
|
public override Tool ProtocolTool => new Tool |
||||
|
{ |
||||
|
Name = _name, |
||||
|
Description = _description, |
||||
|
InputSchema = _inputSchema, |
||||
|
OutputSchema = _outputSchema |
||||
|
}; |
||||
|
|
||||
|
public override IReadOnlyList<object> Metadata => Array.Empty<object>(); |
||||
|
|
||||
|
public override ValueTask<CallToolResult> InvokeAsync(RequestContext<CallToolRequestParams> context, CancellationToken cancellationToken) |
||||
|
{ |
||||
|
return _handler(context, cancellationToken); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,36 @@ |
|||||
|
using System; |
||||
|
|
||||
|
namespace Volo.Abp.Cli.Commands.Services; |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Logger interface for MCP operations.
|
||||
|
/// Writes detailed logs to file and critical messages (Warning/Error) to stderr.
|
||||
|
/// Log level is controlled via ABP_MCP_LOG_LEVEL environment variable.
|
||||
|
/// </summary>
|
||||
|
public interface IMcpLogger |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// Logs a debug message. Only written to file when log level is Debug.
|
||||
|
/// </summary>
|
||||
|
void Debug(string source, string message); |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Logs an informational message. Written to file when log level is Debug or Info.
|
||||
|
/// </summary>
|
||||
|
void Info(string source, string message); |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Logs a warning message. Written to file and stderr.
|
||||
|
/// </summary>
|
||||
|
void Warning(string source, string message); |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Logs an error message. Written to file and stderr.
|
||||
|
/// </summary>
|
||||
|
void Error(string source, string message); |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Logs an error message with exception details. Written to file and stderr.
|
||||
|
/// </summary>
|
||||
|
void Error(string source, string message, Exception exception); |
||||
|
} |
||||
@ -0,0 +1,280 @@ |
|||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.IO; |
||||
|
using System.Linq; |
||||
|
using System.Net; |
||||
|
using System.Net.Http; |
||||
|
using System.Text; |
||||
|
using System.Text.Json; |
||||
|
using System.Threading.Tasks; |
||||
|
using Microsoft.Extensions.Logging; |
||||
|
using Volo.Abp.Cli.Commands.Models; |
||||
|
using Volo.Abp.Cli.Http; |
||||
|
using Volo.Abp.DependencyInjection; |
||||
|
using Volo.Abp.IO; |
||||
|
|
||||
|
namespace Volo.Abp.Cli.Commands.Services; |
||||
|
|
||||
|
public class McpHttpClientService : ISingletonDependency |
||||
|
{ |
||||
|
private static readonly JsonSerializerOptions JsonSerializerOptionsWeb = new(JsonSerializerDefaults.Web); |
||||
|
|
||||
|
private const string LogSource = nameof(McpHttpClientService); |
||||
|
|
||||
|
private readonly CliHttpClientFactory _httpClientFactory; |
||||
|
private readonly ILogger<McpHttpClientService> _logger; |
||||
|
private readonly IMcpLogger _mcpLogger; |
||||
|
private readonly Lazy<Task<string>> _cachedServerUrlLazy; |
||||
|
private List<string> _validToolNames; |
||||
|
private bool _toolDefinitionsLoaded; |
||||
|
|
||||
|
public McpHttpClientService( |
||||
|
CliHttpClientFactory httpClientFactory, |
||||
|
ILogger<McpHttpClientService> logger, |
||||
|
IMcpLogger mcpLogger) |
||||
|
{ |
||||
|
_httpClientFactory = httpClientFactory; |
||||
|
_logger = logger; |
||||
|
_mcpLogger = mcpLogger; |
||||
|
_cachedServerUrlLazy = new Lazy<Task<string>>(GetMcpServerUrlInternalAsync); |
||||
|
} |
||||
|
|
||||
|
public void InitializeToolNames(List<McpToolDefinition> tools) |
||||
|
{ |
||||
|
_validToolNames = tools.Select(t => t.Name).ToList(); |
||||
|
_toolDefinitionsLoaded = true; |
||||
|
_mcpLogger.Debug(LogSource, $"Initialized tool names from cache. Count={tools.Count}, Instance={GetHashCode()}"); |
||||
|
} |
||||
|
|
||||
|
public async Task<string> CallToolAsync(string toolName, JsonElement arguments) |
||||
|
{ |
||||
|
_mcpLogger.Debug(LogSource, $"CallToolAsync called for '{toolName}'. _toolDefinitionsLoaded={_toolDefinitionsLoaded}, Instance={GetHashCode()}"); |
||||
|
|
||||
|
if (!_toolDefinitionsLoaded) |
||||
|
{ |
||||
|
throw new CliUsageException("Tool definitions have not been loaded yet. This is an internal error."); |
||||
|
} |
||||
|
|
||||
|
// Validate toolName against whitelist to prevent malicious input
|
||||
|
if (_validToolNames != null && !_validToolNames.Contains(toolName)) |
||||
|
{ |
||||
|
_mcpLogger.Warning(LogSource, $"Attempted to call unknown tool: {toolName}"); |
||||
|
return CreateErrorResponse($"Unknown tool: {toolName}"); |
||||
|
} |
||||
|
|
||||
|
var baseUrl = await GetMcpServerUrlAsync(); |
||||
|
var url = $"{baseUrl}/tools/call"; |
||||
|
|
||||
|
try |
||||
|
{ |
||||
|
using var httpClient = _httpClientFactory.CreateClient(needsAuthentication: true); |
||||
|
|
||||
|
var jsonContent = JsonSerializer.Serialize( |
||||
|
new { name = toolName, arguments }, |
||||
|
JsonSerializerOptionsWeb); |
||||
|
|
||||
|
var content = new StringContent(jsonContent, Encoding.UTF8, "application/json"); |
||||
|
|
||||
|
var response = await httpClient.PostAsync(url, content); |
||||
|
|
||||
|
if (!response.IsSuccessStatusCode) |
||||
|
{ |
||||
|
_mcpLogger.Error(LogSource, $"API call failed with status: {response.StatusCode}"); |
||||
|
|
||||
|
// Return sanitized error message to client
|
||||
|
var errorMessage = GetSanitizedHttpErrorMessage(response.StatusCode); |
||||
|
return CreateErrorResponse(errorMessage); |
||||
|
} |
||||
|
|
||||
|
return await response.Content.ReadAsStringAsync(); |
||||
|
} |
||||
|
catch (HttpRequestException ex) |
||||
|
{ |
||||
|
_mcpLogger.Error(LogSource, $"Network error calling tool '{toolName}'", ex); |
||||
|
|
||||
|
// Return sanitized error to client
|
||||
|
return CreateErrorResponse(ErrorMessages.NetworkConnectivity); |
||||
|
} |
||||
|
catch (TaskCanceledException ex) |
||||
|
{ |
||||
|
_mcpLogger.Error(LogSource, $"Timeout calling tool '{toolName}'", ex); |
||||
|
|
||||
|
// Return sanitized error to client
|
||||
|
return CreateErrorResponse(ErrorMessages.Timeout); |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
_mcpLogger.Error(LogSource, $"Unexpected error calling tool '{toolName}'", ex); |
||||
|
|
||||
|
// Return generic sanitized error to client
|
||||
|
return CreateErrorResponse(ErrorMessages.Unexpected); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public async Task<bool> CheckServerHealthAsync() |
||||
|
{ |
||||
|
var baseUrl = await GetMcpServerUrlAsync(); |
||||
|
|
||||
|
try |
||||
|
{ |
||||
|
using var httpClient = _httpClientFactory.CreateClient(needsAuthentication: false); |
||||
|
var response = await httpClient.GetAsync(baseUrl); |
||||
|
return response.IsSuccessStatusCode; |
||||
|
} |
||||
|
catch (Exception) |
||||
|
{ |
||||
|
// Silently fail health check - it's optional
|
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public async Task<List<McpToolDefinition>> GetToolDefinitionsAsync() |
||||
|
{ |
||||
|
_mcpLogger.Debug(LogSource, $"GetToolDefinitionsAsync called. Instance={GetHashCode()}"); |
||||
|
|
||||
|
var baseUrl = await GetMcpServerUrlAsync(); |
||||
|
var url = $"{baseUrl}/tools"; |
||||
|
|
||||
|
try |
||||
|
{ |
||||
|
using var httpClient = _httpClientFactory.CreateClient(needsAuthentication: true); |
||||
|
var response = await httpClient.GetAsync(url); |
||||
|
|
||||
|
if (!response.IsSuccessStatusCode) |
||||
|
{ |
||||
|
_mcpLogger.Error(LogSource, $"Failed to fetch tool definitions with status: {response.StatusCode}"); |
||||
|
|
||||
|
// Throw sanitized exception
|
||||
|
var errorMessage = GetSanitizedHttpErrorMessage(response.StatusCode); |
||||
|
throw new CliUsageException($"Failed to fetch tool definitions: {errorMessage}"); |
||||
|
} |
||||
|
|
||||
|
var responseContent = await response.Content.ReadAsStringAsync(); |
||||
|
|
||||
|
// The API returns { tools: [...] } format
|
||||
|
var result = JsonSerializer.Deserialize<McpToolsResponse>(responseContent, JsonSerializerOptionsWeb); |
||||
|
var tools = result?.Tools ?? new List<McpToolDefinition>(); |
||||
|
|
||||
|
// Cache tool names for validation
|
||||
|
_validToolNames = tools.Select(t => t.Name).ToList(); |
||||
|
_toolDefinitionsLoaded = true; |
||||
|
|
||||
|
_mcpLogger.Debug(LogSource, $"Tool definitions loaded successfully. _toolDefinitionsLoaded={_toolDefinitionsLoaded}, Tool count={tools.Count}, Instance={GetHashCode()}"); |
||||
|
|
||||
|
return tools; |
||||
|
} |
||||
|
catch (HttpRequestException ex) |
||||
|
{ |
||||
|
throw CreateHttpExceptionWithInner(ex, "Network error fetching tool definitions"); |
||||
|
} |
||||
|
catch (TaskCanceledException ex) |
||||
|
{ |
||||
|
throw CreateHttpExceptionWithInner(ex, "Timeout fetching tool definitions"); |
||||
|
} |
||||
|
catch (JsonException ex) |
||||
|
{ |
||||
|
throw CreateHttpExceptionWithInner(ex, "JSON parsing error"); |
||||
|
} |
||||
|
catch (CliUsageException) |
||||
|
{ |
||||
|
// Already sanitized, rethrow as-is
|
||||
|
throw; |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
throw CreateHttpExceptionWithInner(ex, "Unexpected error fetching tool definitions"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private async Task<string> GetMcpServerUrlAsync() |
||||
|
{ |
||||
|
return await _cachedServerUrlLazy.Value; |
||||
|
} |
||||
|
|
||||
|
private async Task<string> GetMcpServerUrlInternalAsync() |
||||
|
{ |
||||
|
// Check config file
|
||||
|
if (File.Exists(CliPaths.McpConfig)) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
var json = await FileHelper.ReadAllTextAsync(CliPaths.McpConfig); |
||||
|
var config = JsonSerializer.Deserialize<McpConfig>(json, JsonSerializerOptionsWeb); |
||||
|
if (!string.IsNullOrWhiteSpace(config?.ServerUrl)) |
||||
|
{ |
||||
|
return config.ServerUrl.TrimEnd('/'); |
||||
|
} |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
_logger.LogWarning(ex, "Failed to read MCP config file"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Return default
|
||||
|
return CliConsts.DefaultMcpServerUrl; |
||||
|
} |
||||
|
|
||||
|
private string CreateErrorResponse(string errorMessage) |
||||
|
{ |
||||
|
return JsonSerializer.Serialize(new |
||||
|
{ |
||||
|
content = new[] |
||||
|
{ |
||||
|
new |
||||
|
{ |
||||
|
type = "text", |
||||
|
text = errorMessage |
||||
|
} |
||||
|
}, |
||||
|
isError = true |
||||
|
}, JsonSerializerOptionsWeb); |
||||
|
} |
||||
|
|
||||
|
private string GetSanitizedHttpErrorMessage(HttpStatusCode statusCode) |
||||
|
{ |
||||
|
return statusCode switch |
||||
|
{ |
||||
|
HttpStatusCode.Unauthorized => "Authentication failed. Please ensure you are logged in with a valid account.", |
||||
|
HttpStatusCode.Forbidden => "Access denied. You do not have permission to use this tool.", |
||||
|
HttpStatusCode.NotFound => "The requested tool could not be found. It may have been removed or is temporarily unavailable.", |
||||
|
HttpStatusCode.BadRequest => "The tool request was invalid. Please check your input parameters and try again.", |
||||
|
(HttpStatusCode)429 => "Rate limit exceeded. Please wait a moment before trying again.", // TooManyRequests not available in .NET Standard 2.0
|
||||
|
HttpStatusCode.ServiceUnavailable => "The service is temporarily unavailable. Please try again later.", |
||||
|
HttpStatusCode.InternalServerError => "The tool execution encountered an internal error. Please try again later.", |
||||
|
_ => "The tool execution failed. Please try again later." |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
private CliUsageException CreateHttpExceptionWithInner(Exception ex, string context) |
||||
|
{ |
||||
|
_mcpLogger.Error(LogSource, context, ex); |
||||
|
|
||||
|
var userMessage = ex switch |
||||
|
{ |
||||
|
HttpRequestException => "Network connectivity issue. Please check your internet connection and try again.", |
||||
|
TaskCanceledException => "Request timed out. Please try again.", |
||||
|
JsonException => "Invalid response format received.", |
||||
|
_ => "An unexpected error occurred. Please try again later." |
||||
|
}; |
||||
|
|
||||
|
return new CliUsageException($"Failed to fetch tool definitions: {userMessage}", ex); |
||||
|
} |
||||
|
|
||||
|
private static class ErrorMessages |
||||
|
{ |
||||
|
public const string NetworkConnectivity = "The tool execution failed due to a network connectivity issue. Please check your internet connection and try again."; |
||||
|
public const string Timeout = "The tool execution timed out. The operation took too long to complete. Please try again."; |
||||
|
public const string Unexpected = "The tool execution failed due to an unexpected error. Please try again later."; |
||||
|
} |
||||
|
|
||||
|
private class McpConfig |
||||
|
{ |
||||
|
public string ServerUrl { get; set; } |
||||
|
} |
||||
|
|
||||
|
private class McpToolsResponse |
||||
|
{ |
||||
|
public List<McpToolDefinition> Tools { get; set; } |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,150 @@ |
|||||
|
using System; |
||||
|
using Microsoft.Extensions.Logging; |
||||
|
using Volo.Abp.DependencyInjection; |
||||
|
|
||||
|
namespace Volo.Abp.Cli.Commands.Services; |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// MCP logger implementation that writes to both file (via Serilog) and stderr.
|
||||
|
/// - All logs at or above the configured level are written to file via ILogger
|
||||
|
/// - Warning and Error logs are also written to stderr
|
||||
|
/// - Log level is controlled via ABP_MCP_LOG_LEVEL environment variable
|
||||
|
/// </summary>
|
||||
|
public class McpLogger : IMcpLogger, ISingletonDependency |
||||
|
{ |
||||
|
private const string LogPrefix = "[MCP]"; |
||||
|
|
||||
|
private readonly ILogger<McpLogger> _logger; |
||||
|
private readonly McpLogLevel _configuredLogLevel; |
||||
|
|
||||
|
public McpLogger(ILogger<McpLogger> logger) |
||||
|
{ |
||||
|
_logger = logger; |
||||
|
_configuredLogLevel = GetConfiguredLogLevel(); |
||||
|
} |
||||
|
|
||||
|
public void Debug(string source, string message) |
||||
|
{ |
||||
|
Log(McpLogLevel.Debug, source, message); |
||||
|
} |
||||
|
|
||||
|
public void Info(string source, string message) |
||||
|
{ |
||||
|
Log(McpLogLevel.Info, source, message); |
||||
|
} |
||||
|
|
||||
|
public void Warning(string source, string message) |
||||
|
{ |
||||
|
Log(McpLogLevel.Warning, source, message); |
||||
|
} |
||||
|
|
||||
|
public void Error(string source, string message) |
||||
|
{ |
||||
|
Log(McpLogLevel.Error, source, message); |
||||
|
} |
||||
|
|
||||
|
public void Error(string source, string message, Exception exception) |
||||
|
{ |
||||
|
#if DEBUG
|
||||
|
var fullMessage = $"{message} | Exception: {exception.GetType().Name}: {exception.Message}"; |
||||
|
#else
|
||||
|
var fullMessage = $"{message} | Exception: {exception.GetType().Name}"; |
||||
|
#endif
|
||||
|
Log(McpLogLevel.Error, source, fullMessage); |
||||
|
} |
||||
|
|
||||
|
private void Log(McpLogLevel level, string source, string message) |
||||
|
{ |
||||
|
if (_configuredLogLevel == McpLogLevel.None || level < _configuredLogLevel) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
var mcpFormattedMessage = $"{LogPrefix}[{source}] {message}"; |
||||
|
|
||||
|
// File logging via Serilog
|
||||
|
switch (level) |
||||
|
{ |
||||
|
case McpLogLevel.Debug: |
||||
|
_logger.LogDebug(mcpFormattedMessage); |
||||
|
break; |
||||
|
case McpLogLevel.Info: |
||||
|
_logger.LogInformation(mcpFormattedMessage); |
||||
|
break; |
||||
|
case McpLogLevel.Warning: |
||||
|
_logger.LogWarning(mcpFormattedMessage); |
||||
|
break; |
||||
|
case McpLogLevel.Error: |
||||
|
_logger.LogError(mcpFormattedMessage); |
||||
|
break; |
||||
|
} |
||||
|
|
||||
|
// Stderr output for MCP protocol (Warning/Error only)
|
||||
|
if (level >= McpLogLevel.Warning) |
||||
|
{ |
||||
|
WriteToStderr(level.ToString().ToUpperInvariant(), message); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private void WriteToStderr(string level, string message) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
// Use synchronous write to avoid async issues in MCP context
|
||||
|
Console.Error.WriteLine($"{LogPrefix}[{level}] {message}"); |
||||
|
} |
||||
|
catch |
||||
|
{ |
||||
|
// Silently ignore stderr write errors
|
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private static McpLogLevel GetConfiguredLogLevel() |
||||
|
{ |
||||
|
var envValue = Environment.GetEnvironmentVariable(CliConsts.McpLogLevelEnvironmentVariable); |
||||
|
var isEmpty = string.IsNullOrWhiteSpace(envValue); |
||||
|
|
||||
|
#if DEBUG
|
||||
|
// In development builds, allow full control via environment variable
|
||||
|
if (isEmpty) |
||||
|
{ |
||||
|
return McpLogLevel.Info; // Default level
|
||||
|
} |
||||
|
|
||||
|
return ParseLogLevel(envValue, allowDebug: true); |
||||
|
#else
|
||||
|
// In release builds, restrict to Warning or higher (ignore env variable for Debug/Info)
|
||||
|
if (isEmpty) |
||||
|
{ |
||||
|
return McpLogLevel.Info; // Default level
|
||||
|
} |
||||
|
|
||||
|
return ParseLogLevel(envValue, allowDebug: false); |
||||
|
#endif
|
||||
|
} |
||||
|
|
||||
|
private static McpLogLevel ParseLogLevel(string value, bool allowDebug) |
||||
|
{ |
||||
|
return value.ToLowerInvariant() switch |
||||
|
{ |
||||
|
"debug" => allowDebug ? McpLogLevel.Debug : McpLogLevel.Info, |
||||
|
"info" => McpLogLevel.Info, |
||||
|
"warning" => McpLogLevel.Warning, |
||||
|
"error" => McpLogLevel.Error, |
||||
|
"none" => McpLogLevel.None, |
||||
|
_ => McpLogLevel.Info |
||||
|
}; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Log levels for MCP logging.
|
||||
|
/// </summary>
|
||||
|
public enum McpLogLevel |
||||
|
{ |
||||
|
Debug = 0, |
||||
|
Info = 1, |
||||
|
Warning = 2, |
||||
|
Error = 3, |
||||
|
None = 4 |
||||
|
} |
||||
@ -0,0 +1,181 @@ |
|||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Text.Json; |
||||
|
using System.Threading; |
||||
|
using System.Threading.Tasks; |
||||
|
using Microsoft.Extensions.Logging; |
||||
|
using Microsoft.Extensions.Logging.Abstractions; |
||||
|
using ModelContextProtocol.Protocol; |
||||
|
using ModelContextProtocol.Server; |
||||
|
using Volo.Abp.Cli.Commands.Models; |
||||
|
using Volo.Abp.DependencyInjection; |
||||
|
|
||||
|
namespace Volo.Abp.Cli.Commands.Services; |
||||
|
|
||||
|
public class McpServerService : ITransientDependency |
||||
|
{ |
||||
|
private const string LogSource = nameof(McpServerService); |
||||
|
private const int MaxLogResponseLength = 500; |
||||
|
|
||||
|
private static readonly JsonSerializerOptions JsonCamelCaseOptions = new() |
||||
|
{ |
||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase |
||||
|
}; |
||||
|
|
||||
|
private static class ToolErrorMessages |
||||
|
{ |
||||
|
public const string InvalidResponseFormat = "The tool execution completed but returned an invalid response format. Please try again."; |
||||
|
public const string UnexpectedError = "The tool execution failed due to an unexpected error. Please try again later."; |
||||
|
} |
||||
|
|
||||
|
private readonly McpHttpClientService _mcpHttpClient; |
||||
|
private readonly McpToolsCacheService _toolsCacheService; |
||||
|
private readonly IMcpLogger _mcpLogger; |
||||
|
|
||||
|
public McpServerService( |
||||
|
McpHttpClientService mcpHttpClient, |
||||
|
McpToolsCacheService toolsCacheService, |
||||
|
IMcpLogger mcpLogger) |
||||
|
{ |
||||
|
_mcpHttpClient = mcpHttpClient; |
||||
|
_toolsCacheService = toolsCacheService; |
||||
|
_mcpLogger = mcpLogger; |
||||
|
} |
||||
|
|
||||
|
public async Task RunAsync(CancellationToken cancellationToken = default) |
||||
|
{ |
||||
|
_mcpLogger.Info(LogSource, "Starting ABP MCP Server (stdio)"); |
||||
|
|
||||
|
var options = new McpServerOptions(); |
||||
|
|
||||
|
await RegisterAllToolsAsync(options); |
||||
|
|
||||
|
// Use NullLoggerFactory to prevent ModelContextProtocol library from logging to stdout
|
||||
|
// All our logging goes to file and stderr via IMcpLogger
|
||||
|
var server = McpServer.Create( |
||||
|
new StdioServerTransport("abp-mcp-server", NullLoggerFactory.Instance), |
||||
|
options |
||||
|
); |
||||
|
|
||||
|
await server.RunAsync(cancellationToken); |
||||
|
|
||||
|
_mcpLogger.Info(LogSource, "ABP MCP Server stopped"); |
||||
|
} |
||||
|
|
||||
|
private async Task RegisterAllToolsAsync(McpServerOptions options) |
||||
|
{ |
||||
|
// Get tool definitions from cache (or fetch from server)
|
||||
|
var toolDefinitions = await _toolsCacheService.GetToolDefinitionsAsync(); |
||||
|
|
||||
|
_mcpLogger.Info(LogSource, $"Registering {toolDefinitions.Count} tools"); |
||||
|
|
||||
|
// Register each tool dynamically
|
||||
|
foreach (var toolDef in toolDefinitions) |
||||
|
{ |
||||
|
RegisterToolFromDefinition(options, toolDef); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private void RegisterToolFromDefinition(McpServerOptions options, McpToolDefinition toolDef) |
||||
|
{ |
||||
|
var inputSchema = toolDef.InputSchema ?? new McpToolInputSchema(); |
||||
|
RegisterTool(options, toolDef.Name, toolDef.Description, inputSchema, toolDef.OutputSchema); |
||||
|
} |
||||
|
|
||||
|
private static CallToolResult CreateErrorResult(string errorMessage) |
||||
|
{ |
||||
|
return new CallToolResult |
||||
|
{ |
||||
|
Content = new List<ContentBlock> |
||||
|
{ |
||||
|
new TextContentBlock |
||||
|
{ |
||||
|
Text = errorMessage |
||||
|
} |
||||
|
}, |
||||
|
IsError = true |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
private void RegisterTool( |
||||
|
McpServerOptions options, |
||||
|
string name, |
||||
|
string description, |
||||
|
object inputSchema, |
||||
|
JsonElement? outputSchema) |
||||
|
{ |
||||
|
if (options.ToolCollection == null) |
||||
|
{ |
||||
|
options.ToolCollection = new McpServerPrimitiveCollection<McpServerTool>(); |
||||
|
} |
||||
|
|
||||
|
var tool = new AbpMcpServerTool( |
||||
|
name, |
||||
|
description, |
||||
|
JsonSerializer.SerializeToElement(inputSchema, JsonCamelCaseOptions), |
||||
|
outputSchema, |
||||
|
(context, cancellationToken) => HandleToolInvocationAsync(name, context, cancellationToken) |
||||
|
); |
||||
|
|
||||
|
options.ToolCollection.Add(tool); |
||||
|
} |
||||
|
|
||||
|
private async ValueTask<CallToolResult> HandleToolInvocationAsync( |
||||
|
string toolName, |
||||
|
RequestContext<CallToolRequestParams> context, |
||||
|
CancellationToken cancellationToken) |
||||
|
{ |
||||
|
_mcpLogger.Debug(LogSource, $"Tool '{toolName}' called with arguments: {context.Params.Arguments}"); |
||||
|
|
||||
|
try |
||||
|
{ |
||||
|
var argumentsJson = JsonSerializer.SerializeToElement(context.Params.Arguments); |
||||
|
var resultJson = await _mcpHttpClient.CallToolAsync(toolName, argumentsJson); |
||||
|
|
||||
|
var callToolResult = TryDeserializeResult(resultJson, toolName); |
||||
|
if (callToolResult != null) |
||||
|
{ |
||||
|
LogToolResult(toolName, callToolResult); |
||||
|
return callToolResult; |
||||
|
} |
||||
|
|
||||
|
return CreateErrorResult(ToolErrorMessages.InvalidResponseFormat); |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
_mcpLogger.Error(LogSource, $"Tool '{toolName}' execution failed '{ex.Message}'", ex); |
||||
|
return CreateErrorResult(ToolErrorMessages.UnexpectedError); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private CallToolResult TryDeserializeResult(string resultJson, string toolName) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
return JsonSerializer.Deserialize<CallToolResult>(resultJson); |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
_mcpLogger.Error(LogSource, $"Failed to deserialize response as CallToolResult: {ex.Message}"); |
||||
|
|
||||
|
var logResponse = resultJson.Length <= MaxLogResponseLength |
||||
|
? resultJson |
||||
|
: resultJson.Substring(0, MaxLogResponseLength); |
||||
|
_mcpLogger.Debug(LogSource, $"Response was: {logResponse}"); |
||||
|
|
||||
|
return null; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private void LogToolResult(string toolName, CallToolResult result) |
||||
|
{ |
||||
|
if (result.IsError == true) |
||||
|
{ |
||||
|
_mcpLogger.Warning(LogSource, $"Tool '{toolName}' returned an error"); |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
_mcpLogger.Debug(LogSource, $"Tool '{toolName}' executed successfully"); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,183 @@ |
|||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Globalization; |
||||
|
using System.IO; |
||||
|
using System.Runtime.InteropServices; |
||||
|
using System.Text.Json; |
||||
|
using System.Threading.Tasks; |
||||
|
using Microsoft.Extensions.Logging; |
||||
|
using Volo.Abp.Cli.Commands.Models; |
||||
|
using Volo.Abp.Cli.Memory; |
||||
|
using Volo.Abp.DependencyInjection; |
||||
|
using Volo.Abp.IO; |
||||
|
|
||||
|
namespace Volo.Abp.Cli.Commands.Services; |
||||
|
|
||||
|
public class McpToolsCacheService : ITransientDependency |
||||
|
{ |
||||
|
private const string LogSource = nameof(McpToolsCacheService); |
||||
|
private const int CacheValidityHours = 24; |
||||
|
|
||||
|
private readonly McpHttpClientService _mcpHttpClient; |
||||
|
private readonly MemoryService _memoryService; |
||||
|
private readonly ILogger<McpToolsCacheService> _logger; |
||||
|
private readonly IMcpLogger _mcpLogger; |
||||
|
|
||||
|
public McpToolsCacheService( |
||||
|
McpHttpClientService mcpHttpClient, |
||||
|
MemoryService memoryService, |
||||
|
ILogger<McpToolsCacheService> logger, |
||||
|
IMcpLogger mcpLogger) |
||||
|
{ |
||||
|
_mcpHttpClient = mcpHttpClient; |
||||
|
_memoryService = memoryService; |
||||
|
_logger = logger; |
||||
|
_mcpLogger = mcpLogger; |
||||
|
} |
||||
|
|
||||
|
public async Task<List<McpToolDefinition>> GetToolDefinitionsAsync() |
||||
|
{ |
||||
|
if (await IsCacheValidAsync()) |
||||
|
{ |
||||
|
var cachedTools = await LoadFromCacheAsync(); |
||||
|
if (cachedTools != null) |
||||
|
{ |
||||
|
_mcpLogger.Debug(LogSource, "Using cached tool definitions"); |
||||
|
// Initialize the HTTP client's tool names list from cache
|
||||
|
_mcpHttpClient.InitializeToolNames(cachedTools); |
||||
|
return cachedTools; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Cache is invalid or missing, fetch from server
|
||||
|
_mcpLogger.Info(LogSource, "Fetching tool definitions from server..."); |
||||
|
var tools = await _mcpHttpClient.GetToolDefinitionsAsync(); |
||||
|
|
||||
|
// Validate that we got tools
|
||||
|
if (tools == null || tools.Count == 0) |
||||
|
{ |
||||
|
throw new CliUsageException( |
||||
|
"Failed to fetch tool definitions from ABP.IO MCP Server. " + |
||||
|
"No tools available. The MCP server cannot start without tool definitions."); |
||||
|
} |
||||
|
|
||||
|
// Save tools to cache
|
||||
|
await SaveToCacheAsync(tools); |
||||
|
await _memoryService.SetAsync(CliConsts.MemoryKeys.McpToolsLastFetchDate, DateTime.Now.ToString(CultureInfo.InvariantCulture)); |
||||
|
|
||||
|
_mcpLogger.Info(LogSource, $"Successfully fetched and cached {tools.Count} tool definitions"); |
||||
|
return tools; |
||||
|
} |
||||
|
|
||||
|
private async Task<bool> IsCacheValidAsync() |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
// Check if cache file exists
|
||||
|
if (!File.Exists(CliPaths.McpToolsCache)) |
||||
|
{ |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
// Check timestamp in memory
|
||||
|
var lastFetchTimeString = await _memoryService.GetAsync(CliConsts.MemoryKeys.McpToolsLastFetchDate); |
||||
|
if (string.IsNullOrEmpty(lastFetchTimeString)) |
||||
|
{ |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
if (DateTime.TryParse(lastFetchTimeString, CultureInfo.InvariantCulture, DateTimeStyles.None, out var lastFetchTime)) |
||||
|
{ |
||||
|
// Check if less than configured hours old
|
||||
|
if (DateTime.Now.Subtract(lastFetchTime).TotalHours < CacheValidityHours) |
||||
|
{ |
||||
|
return true; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return false; |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
_logger.LogWarning($"Error checking cache validity: {ex.Message}"); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private async Task<List<McpToolDefinition>> LoadFromCacheAsync() |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
if (!File.Exists(CliPaths.McpToolsCache)) |
||||
|
{ |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
var json = await FileHelper.ReadAllTextAsync(CliPaths.McpToolsCache); |
||||
|
var tools = JsonSerializer.Deserialize<List<McpToolDefinition>>(json, new JsonSerializerOptions |
||||
|
{ |
||||
|
PropertyNameCaseInsensitive = true |
||||
|
}); |
||||
|
|
||||
|
return tools; |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
_logger.LogWarning($"Error loading cached tool definitions: {ex.Message}"); |
||||
|
return null; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private Task SaveToCacheAsync(List<McpToolDefinition> tools) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
// Ensure directory exists
|
||||
|
var directory = Path.GetDirectoryName(CliPaths.McpToolsCache); |
||||
|
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) |
||||
|
{ |
||||
|
Directory.CreateDirectory(directory); |
||||
|
} |
||||
|
|
||||
|
var json = JsonSerializer.Serialize(tools, new JsonSerializerOptions |
||||
|
{ |
||||
|
WriteIndented = true, |
||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase |
||||
|
}); |
||||
|
|
||||
|
// Using synchronous File.WriteAllText is acceptable here since cache writes are not on the critical path
|
||||
|
// and we need to support multiple target frameworks
|
||||
|
File.WriteAllText(CliPaths.McpToolsCache, json); |
||||
|
|
||||
|
// Set restrictive file permissions (user read/write only)
|
||||
|
SetRestrictiveFilePermissions(CliPaths.McpToolsCache); |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
_logger.LogWarning($"Error saving tool definitions to cache: {ex.Message}"); |
||||
|
} |
||||
|
|
||||
|
return Task.CompletedTask; |
||||
|
} |
||||
|
|
||||
|
private void SetRestrictiveFilePermissions(string filePath) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
// On Unix systems, set permissions to 600 (user read/write only)
|
||||
|
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) |
||||
|
{ |
||||
|
#if NET6_0_OR_GREATER
|
||||
|
File.SetUnixFileMode(filePath, UnixFileMode.UserRead | UnixFileMode.UserWrite); |
||||
|
#endif
|
||||
|
} |
||||
|
// On Windows, the file inherits permissions from the user profile directory,
|
||||
|
// which is already restrictive to the current user
|
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
_logger.LogWarning($"Error setting file permissions: {ex.Message}"); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
Loading…
Reference in new issue