Browse Source

Merge pull request #24699 from abpframework/auto-merge/rel-10-1/4298

Merge branch dev with rel-10.1
pull/24712/head
Ma Liming 2 weeks ago
committed by GitHub
parent
commit
9d29d7cd43
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      .gitignore
  2. 3
      Directory.Packages.props
  3. 1
      framework/src/Volo.Abp.Cli.Core/Volo.Abp.Cli.Core.csproj
  4. 1
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/AbpCliCoreModule.cs
  5. 11
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Args/CommandLineArgsExtensions.cs
  6. 6
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/CliConsts.cs
  7. 5
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/CliPaths.cs
  8. 45
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/CliService.cs
  9. 2
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/CommandSelector.cs
  10. 2
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/HelpCommand.cs
  11. 197
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/McpCommand.cs
  12. 23
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Models/McpClientConfiguration.cs
  13. 26
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Models/McpToolDefinition.cs
  14. 47
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/AbpMcpServerTool.cs
  15. 36
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/IMcpLogger.cs
  16. 280
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpHttpClientService.cs
  17. 150
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpLogger.cs
  18. 181
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpServerService.cs
  19. 183
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpToolsCacheService.cs
  20. 23
      framework/src/Volo.Abp.Cli/Volo/Abp/Cli/Program.cs
  21. 3
      framework/src/Volo.Abp.Core/Volo/Abp/Internal/Telemetry/Constants/ActivityNameConsts.cs

2
.gitignore

@ -328,4 +328,4 @@ deploy/_run_all_log.txt
# No commit yarn.lock files in the subfolders of templates directory
templates/**/yarn.lock
templates/app-nolayers/aspnet-core/MyCompanyName.MyProjectName.Mvc/Logs/logs.txt
templates/module/aspnet-core/src/MyCompanyName.MyProjectName.Web/Properties/launchSettings.json
templates/module/aspnet-core/src/MyCompanyName.MyProjectName.Web/Properties/launchSettings.json

3
Directory.Packages.props

@ -30,6 +30,7 @@
<PackageVersion Include="Dapper" Version="2.1.66" />
<PackageVersion Include="Dapr.AspNetCore" Version="1.16.0" />
<PackageVersion Include="Dapr.Client" Version="1.16.0" />
<PackageVersion Include="ModelContextProtocol" Version="0.5.0-preview.1" />
<PackageVersion Include="MyCSharp.HttpUserAgentParser" Version="3.0.28" />
<PackageVersion Include="Devart.Data.Oracle.EFCore" Version="11.0.0.9" />
<PackageVersion Include="DistributedLock.Core" Version="1.0.8" />
@ -195,4 +196,4 @@
<PackageVersion Include="Fody" Version="6.9.3" />
<PackageVersion Include="System.Management" Version="10.0.2"/>
</ItemGroup>
</Project>
</Project>

1
framework/src/Volo.Abp.Cli.Core/Volo.Abp.Cli.Core.csproj

@ -14,6 +14,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.FileSystemGlobbing" />
<PackageReference Include="ModelContextProtocol" />
<PackageReference Include="SharpZipLib" />
<PackageReference Include="NuGet.Versioning" />
<PackageReference Include="System.Security.Permissions" />

1
framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/AbpCliCoreModule.cs

@ -79,6 +79,7 @@ public class AbpCliCoreModule : AbpModule
options.Commands[ClearDownloadCacheCommand.Name] = typeof(ClearDownloadCacheCommand);
options.Commands[RecreateInitialMigrationCommand.Name] = typeof(RecreateInitialMigrationCommand);
options.Commands[GenerateRazorPage.Name] = typeof(GenerateRazorPage);
options.Commands[McpCommand.Name] = typeof(McpCommand);
options.DisabledModulesToAddToSolution.Add("Volo.Abp.LeptonXTheme.Pro");
options.DisabledModulesToAddToSolution.Add("Volo.Abp.LeptonXTheme.Lite");

11
framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Args/CommandLineArgsExtensions.cs

@ -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);
}
}

6
framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/CliConsts.cs

@ -1,4 +1,4 @@
namespace Volo.Abp.Cli;
namespace Volo.Abp.Cli;
public static class CliConsts
{
@ -20,8 +20,12 @@ public static class CliConsts
public static string AppSettingsSecretJsonFileName = "appsettings.secrets.json";
public const string McpLogLevelEnvironmentVariable = "ABP_MCP_LOG_LEVEL";
public const string DefaultMcpServerUrl = "https://mcp.abp.io";
public static class MemoryKeys
{
public const string LatestCliVersionCheckDate = "LatestCliVersionCheckDate";
public const string McpToolsLastFetchDate = "McpToolsLastFetchDate";
}
}

5
framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/CliPaths.cs

@ -1,4 +1,4 @@
using System;
using System;
using System.IO;
using System.Text;
@ -14,6 +14,9 @@ public static class CliPaths
public static string Memory => Path.Combine(Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location)!, "memory.bin");
public static string Build => Path.Combine(AbpRootPath, "build");
public static string Lic => Path.Combine(Path.GetTempPath(), Encoding.ASCII.GetString(new byte[] { 65, 98, 112, 76, 105, 99, 101, 110, 115, 101, 46, 98, 105, 110 }));
public static string McpToolsCache => Path.Combine(Root, "mcp-tools.json");
public static string McpLog => Path.Combine(Log, "mcp.log");
public static string McpConfig => Path.Combine(Root, "mcp-config.json");
public static readonly string AbpRootPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".abp");
}

45
framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/CliService.cs

@ -1,4 +1,4 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using NuGet.Versioning;
@ -10,6 +10,7 @@ using System.Reflection;
using System.Threading.Tasks;
using Volo.Abp.Cli.Args;
using Volo.Abp.Cli.Commands;
using Volo.Abp.Cli.Commands.Services;
using Volo.Abp.Cli.Memory;
using Volo.Abp.Cli.Version;
using Volo.Abp.Cli.Utils;
@ -21,8 +22,11 @@ namespace Volo.Abp.Cli;
public class CliService : ITransientDependency
{
private const string McpLogSource = nameof(CliService);
private readonly MemoryService _memoryService;
private readonly ITelemetryService _telemetryService;
private readonly IMcpLogger _mcpLogger;
public ILogger<CliService> Logger { get; set; }
protected ICommandLineArgumentParser CommandLineArgumentParser { get; }
protected ICommandSelector CommandSelector { get; }
@ -39,7 +43,8 @@ public class CliService : ITransientDependency
ICmdHelper cmdHelper,
MemoryService memoryService,
CliVersionService cliVersionService,
ITelemetryService telemetryService)
ITelemetryService telemetryService,
IMcpLogger mcpLogger)
{
_memoryService = memoryService;
CommandLineArgumentParser = commandLineArgumentParser;
@ -49,19 +54,27 @@ public class CliService : ITransientDependency
CmdHelper = cmdHelper;
CliVersionService = cliVersionService;
_telemetryService = telemetryService;
_mcpLogger = mcpLogger;
Logger = NullLogger<CliService>.Instance;
}
public async Task RunAsync(string[] args)
{
var currentCliVersion = await CliVersionService.GetCurrentCliVersionAsync();
Logger.LogInformation($"ABP CLI {currentCliVersion}");
var commandLineArgs = CommandLineArgumentParser.Parse(args);
var currentCliVersion = await CliVersionService.GetCurrentCliVersionAsync();
var isMcpCommand = commandLineArgs.IsMcpCommand();
// Don't print banner for MCP command to avoid corrupting stdout JSON-RPC stream
if (!isMcpCommand)
{
Logger.LogInformation($"ABP CLI {currentCliVersion}");
}
#if !DEBUG
if (!commandLineArgs.Options.ContainsKey("skip-cli-version-check"))
// Skip version check for MCP command to avoid corrupting stdout JSON-RPC stream
if (!isMcpCommand && !commandLineArgs.Options.ContainsKey("skip-cli-version-check"))
{
await CheckCliVersionAsync(currentCliVersion);
}
@ -85,13 +98,29 @@ public class CliService : ITransientDependency
}
catch (CliUsageException usageException)
{
Logger.LogWarning(usageException.Message);
// For MCP command, use IMcpLogger to avoid corrupting stdout JSON-RPC stream
if (commandLineArgs.IsMcpCommand())
{
_mcpLogger.Error(McpLogSource, usageException.Message);
}
else
{
Logger.LogWarning(usageException.Message);
}
Environment.ExitCode = 1;
}
catch (Exception ex)
{
await _telemetryService.AddErrorActivityAsync(ex.Message);
Logger.LogException(ex);
// For MCP command, use IMcpLogger to avoid corrupting stdout JSON-RPC stream
if (commandLineArgs.IsMcpCommand())
{
_mcpLogger.Error(McpLogSource, "Fatal error", ex);
}
else
{
Logger.LogException(ex);
}
throw;
}
finally

2
framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/CommandSelector.cs

@ -1,4 +1,4 @@
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Options;
using System;
using System.Collections.Generic;
using Volo.Abp.Cli.Args;

2
framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/HelpCommand.cs

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;

197
framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/McpCommand.cs

@ -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.";
}
}

23
framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Models/McpClientConfiguration.cs

@ -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; }
}

26
framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Models/McpToolDefinition.cs

@ -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; }
}

47
framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/AbpMcpServerTool.cs

@ -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);
}
}

36
framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/IMcpLogger.cs

@ -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);
}

280
framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpHttpClientService.cs

@ -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; }
}
}

150
framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpLogger.cs

@ -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
}

181
framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpServerService.cs

@ -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");
}
}
}

183
framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpToolsCacheService.cs

@ -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}");
}
}
}

23
framework/src/Volo.Abp.Cli/Volo/Abp/Cli/Program.cs

@ -1,4 +1,4 @@
using System;
using System;
using Microsoft.Extensions.DependencyInjection;
using Serilog;
using Serilog.Events;
@ -15,7 +15,7 @@ public class Program
Console.OutputEncoding = System.Text.Encoding.UTF8;
var loggerOutputTemplate = "{Message:lj}{NewLine}{Exception}";
Log.Logger = new LoggerConfiguration()
var config = new LoggerConfiguration()
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.MinimumLevel.Override("Volo.Abp", LogEventLevel.Warning)
@ -26,10 +26,21 @@ public class Program
#else
.MinimumLevel.Override("Volo.Abp.Cli", LogEventLevel.Information)
#endif
.Enrich.FromLogContext()
.WriteTo.File(Path.Combine(CliPaths.Log, "abp-cli-logs.txt"), outputTemplate: loggerOutputTemplate)
.WriteTo.Console(theme: AnsiConsoleTheme.Sixteen, outputTemplate: loggerOutputTemplate)
.CreateLogger();
.Enrich.FromLogContext();
if (args.Length > 0 && args[0].Equals("mcp", StringComparison.OrdinalIgnoreCase))
{
Log.Logger = config
.WriteTo.File(Path.Combine(CliPaths.Log, "abp-cli-mcp-logs.txt"), outputTemplate: loggerOutputTemplate)
.CreateLogger();
}
else
{
Log.Logger = config
.WriteTo.File(Path.Combine(CliPaths.Log, "abp-cli-logs.txt"), outputTemplate: loggerOutputTemplate)
.WriteTo.Console(theme: AnsiConsoleTheme.Sixteen, outputTemplate: loggerOutputTemplate)
.CreateLogger();
}
using (var application = AbpApplicationFactory.Create<AbpCliModule>(
options =>

3
framework/src/Volo.Abp.Core/Volo/Abp/Internal/Telemetry/Constants/ActivityNameConsts.cs

@ -1,4 +1,4 @@
namespace Volo.Abp.Internal.Telemetry.Constants;
namespace Volo.Abp.Internal.Telemetry.Constants;
public static class ActivityNameConsts
{
@ -68,6 +68,7 @@ public static class ActivityNameConsts
public const string AbpCliCommandsInstallModule = "AbpCli.Comands.InstallModule";
public const string AbpCliCommandsInstallLocalModule = "AbpCli.Comands.InstallLocalModule";
public const string AbpCliCommandsListModules = "AbpCli.Comands.ListModules";
public const string AbpCliCommandsMcp = "AbpCli.Commands.Mcp";
public const string AbpCliRun = "AbpCli.Run";
public const string AbpCliExit = "AbpCli.Exit";
public const string ApplicationRun = "Application.Run";

Loading…
Cancel
Save