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