Browse Source

Introduce IMcpLogger for structured MCP logging

Added IMcpLogger interface and McpLogger implementation to provide structured logging for MCP operations, supporting log levels and file rotation. Replaced direct Console.Error logging with IMcpLogger in MCP-related services and commands. Log level is now configurable via the ABP_MCP_LOG_LEVEL environment variable, and logs are written to both file and stderr as appropriate.
pull/24677/head
Mansur Besleney 4 weeks ago
parent
commit
0bc6d1cdff
  1. 1
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/CliConsts.cs
  2. 3
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/CliPaths.cs
  3. 18
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/CliService.cs
  4. 20
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/McpCommand.cs
  5. 36
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/IMcpLogger.cs
  6. 33
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpHttpClientService.cs
  7. 210
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpLogger.cs
  8. 30
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpServerService.cs
  9. 21
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpToolsCacheService.cs

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

@ -21,6 +21,7 @@ public static class CliConsts
public static string AppSettingsSecretJsonFileName = "appsettings.secrets.json"; public static string AppSettingsSecretJsonFileName = "appsettings.secrets.json";
public const string McpServerUrlEnvironmentVariable = "ABP_MCP_SERVER_URL"; public const string McpServerUrlEnvironmentVariable = "ABP_MCP_SERVER_URL";
public const string McpLogLevelEnvironmentVariable = "ABP_MCP_LOG_LEVEL";
public const string DefaultMcpServerUrl = "https://mcp.abp.io"; public const string DefaultMcpServerUrl = "https://mcp.abp.io";
public static class MemoryKeys public static class MemoryKeys

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

@ -1,4 +1,4 @@
using System; using System;
using System.IO; using System.IO;
using System.Text; using System.Text;
@ -15,6 +15,7 @@ public static class CliPaths
public static string Build => Path.Combine(AbpRootPath, "build"); 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 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 McpToolsCache => Path.Combine(Root, "mcp-tools.json");
public static string McpLog => Path.Combine(Log, "mcp.log");
public static readonly string AbpRootPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".abp"); public static readonly string AbpRootPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".abp");
} }

18
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;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using NuGet.Versioning; using NuGet.Versioning;
@ -10,6 +10,7 @@ using System.Reflection;
using System.Threading.Tasks; using System.Threading.Tasks;
using Volo.Abp.Cli.Args; using Volo.Abp.Cli.Args;
using Volo.Abp.Cli.Commands; using Volo.Abp.Cli.Commands;
using Volo.Abp.Cli.Commands.Services;
using Volo.Abp.Cli.Memory; using Volo.Abp.Cli.Memory;
using Volo.Abp.Cli.Version; using Volo.Abp.Cli.Version;
using Volo.Abp.Cli.Utils; using Volo.Abp.Cli.Utils;
@ -21,8 +22,11 @@ namespace Volo.Abp.Cli;
public class CliService : ITransientDependency public class CliService : ITransientDependency
{ {
private const string McpLogSource = nameof(CliService);
private readonly MemoryService _memoryService; private readonly MemoryService _memoryService;
private readonly ITelemetryService _telemetryService; private readonly ITelemetryService _telemetryService;
private readonly IMcpLogger _mcpLogger;
public ILogger<CliService> Logger { get; set; } public ILogger<CliService> Logger { get; set; }
protected ICommandLineArgumentParser CommandLineArgumentParser { get; } protected ICommandLineArgumentParser CommandLineArgumentParser { get; }
protected ICommandSelector CommandSelector { get; } protected ICommandSelector CommandSelector { get; }
@ -39,7 +43,8 @@ public class CliService : ITransientDependency
ICmdHelper cmdHelper, ICmdHelper cmdHelper,
MemoryService memoryService, MemoryService memoryService,
CliVersionService cliVersionService, CliVersionService cliVersionService,
ITelemetryService telemetryService) ITelemetryService telemetryService,
IMcpLogger mcpLogger)
{ {
_memoryService = memoryService; _memoryService = memoryService;
CommandLineArgumentParser = commandLineArgumentParser; CommandLineArgumentParser = commandLineArgumentParser;
@ -49,6 +54,7 @@ public class CliService : ITransientDependency
CmdHelper = cmdHelper; CmdHelper = cmdHelper;
CliVersionService = cliVersionService; CliVersionService = cliVersionService;
_telemetryService = telemetryService; _telemetryService = telemetryService;
_mcpLogger = mcpLogger;
Logger = NullLogger<CliService>.Instance; Logger = NullLogger<CliService>.Instance;
} }
@ -92,10 +98,10 @@ public class CliService : ITransientDependency
} }
catch (CliUsageException usageException) catch (CliUsageException usageException)
{ {
// For MCP command, write errors to stderr to avoid corrupting stdout JSON-RPC stream // For MCP command, use IMcpLogger to avoid corrupting stdout JSON-RPC stream
if (commandLineArgs.IsCommand("mcp")) if (commandLineArgs.IsCommand("mcp"))
{ {
await Console.Error.WriteLineAsync($"[MCP] Error: {usageException.Message}"); _mcpLogger.Error(McpLogSource, usageException.Message);
} }
else else
{ {
@ -106,10 +112,10 @@ public class CliService : ITransientDependency
catch (Exception ex) catch (Exception ex)
{ {
await _telemetryService.AddErrorActivityAsync(ex.Message); await _telemetryService.AddErrorActivityAsync(ex.Message);
// For MCP command, write errors to stderr to avoid corrupting stdout JSON-RPC stream // For MCP command, use IMcpLogger to avoid corrupting stdout JSON-RPC stream
if (commandLineArgs.IsCommand("mcp")) if (commandLineArgs.IsCommand("mcp"))
{ {
await Console.Error.WriteLineAsync($"[MCP] Fatal error: {ex.Message}"); _mcpLogger.Error(McpLogSource, "Fatal error", ex);
} }
else else
{ {

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

@ -20,12 +20,14 @@ namespace Volo.Abp.Cli.Commands;
public class McpCommand : IConsoleCommand, ITransientDependency public class McpCommand : IConsoleCommand, ITransientDependency
{ {
private const string LogSource = nameof(McpCommand);
public const string Name = "mcp"; public const string Name = "mcp";
private readonly AuthService _authService; private readonly AuthService _authService;
private readonly IApiKeyService _apiKeyService; private readonly IApiKeyService _apiKeyService;
private readonly McpServerService _mcpServerService; private readonly McpServerService _mcpServerService;
private readonly McpHttpClientService _mcpHttpClient; private readonly McpHttpClientService _mcpHttpClient;
private readonly IMcpLogger _mcpLogger;
public ILogger<McpCommand> Logger { get; set; } public ILogger<McpCommand> Logger { get; set; }
@ -33,12 +35,14 @@ public class McpCommand : IConsoleCommand, ITransientDependency
IApiKeyService apiKeyService, IApiKeyService apiKeyService,
AuthService authService, AuthService authService,
McpServerService mcpServerService, McpServerService mcpServerService,
McpHttpClientService mcpHttpClient) McpHttpClientService mcpHttpClient,
IMcpLogger mcpLogger)
{ {
_apiKeyService = apiKeyService; _apiKeyService = apiKeyService;
_authService = authService; _authService = authService;
_mcpServerService = mcpServerService; _mcpServerService = mcpServerService;
_mcpHttpClient = mcpHttpClient; _mcpHttpClient = mcpHttpClient;
_mcpLogger = mcpLogger;
Logger = NullLogger<McpCommand>.Instance; Logger = NullLogger<McpCommand>.Instance;
} }
@ -72,17 +76,17 @@ public class McpCommand : IConsoleCommand, ITransientDependency
return; return;
} }
// Check server health before starting (log to stderr) // Check server health before starting
await Console.Error.WriteLineAsync("[MCP] Checking ABP.IO MCP Server connection..."); _mcpLogger.Info(LogSource, "Checking ABP.IO MCP Server connection...");
var isHealthy = await _mcpHttpClient.CheckServerHealthAsync(); var isHealthy = await _mcpHttpClient.CheckServerHealthAsync();
if (!isHealthy) if (!isHealthy)
{ {
await Console.Error.WriteLineAsync("[MCP] Warning: Could not connect to ABP.IO MCP Server. The server might be offline."); _mcpLogger.Warning(LogSource, "Could not connect to ABP.IO MCP Server. The server might be offline.");
await Console.Error.WriteLineAsync("[MCP] Continuing to start local MCP server..."); _mcpLogger.Info(LogSource, "Continuing to start local MCP server...");
} }
await Console.Error.WriteLineAsync("[MCP] Starting ABP MCP Server..."); _mcpLogger.Info(LogSource, "Starting ABP MCP Server...");
var cts = new CancellationTokenSource(); var cts = new CancellationTokenSource();
ConsoleCancelEventHandler cancelHandler = null; ConsoleCancelEventHandler cancelHandler = null;
@ -90,7 +94,7 @@ public class McpCommand : IConsoleCommand, ITransientDependency
cancelHandler = (sender, e) => cancelHandler = (sender, e) =>
{ {
e.Cancel = true; e.Cancel = true;
Console.Error.WriteLine("[MCP] Shutting down ABP MCP Server..."); _mcpLogger.Info(LogSource, "Shutting down ABP MCP Server...");
try try
{ {
@ -114,7 +118,7 @@ public class McpCommand : IConsoleCommand, ITransientDependency
} }
catch (Exception ex) catch (Exception ex)
{ {
await Console.Error.WriteLineAsync($"[MCP] Error running MCP server: {ex.Message}"); _mcpLogger.Error(LogSource, "Error running MCP server", ex);
throw; throw;
} }
finally finally

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

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

@ -15,6 +15,7 @@ namespace Volo.Abp.Cli.Commands.Services;
public class McpHttpClientService : ITransientDependency public class McpHttpClientService : ITransientDependency
{ {
private const string LogSource = nameof(McpHttpClientService);
private static readonly JsonSerializerOptions JsonSerializerOptionsWeb = new(JsonSerializerDefaults.Web); private static readonly JsonSerializerOptions JsonSerializerOptionsWeb = new(JsonSerializerDefaults.Web);
private static class ErrorMessages private static class ErrorMessages
@ -26,16 +27,19 @@ public class McpHttpClientService : ITransientDependency
private readonly CliHttpClientFactory _httpClientFactory; private readonly CliHttpClientFactory _httpClientFactory;
private readonly ILogger<McpHttpClientService> _logger; private readonly ILogger<McpHttpClientService> _logger;
private readonly IMcpLogger _mcpLogger;
private readonly MemoryService _memoryService; private readonly MemoryService _memoryService;
private string _cachedServerUrl; private string _cachedServerUrl;
public McpHttpClientService( public McpHttpClientService(
CliHttpClientFactory httpClientFactory, CliHttpClientFactory httpClientFactory,
ILogger<McpHttpClientService> logger, ILogger<McpHttpClientService> logger,
IMcpLogger mcpLogger,
MemoryService memoryService) MemoryService memoryService)
{ {
_httpClientFactory = httpClientFactory; _httpClientFactory = httpClientFactory;
_logger = logger; _logger = logger;
_mcpLogger = mcpLogger;
_memoryService = memoryService; _memoryService = memoryService;
} }
@ -87,8 +91,7 @@ public class McpHttpClientService : ITransientDependency
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
// Log detailed error to stderr for debugging _mcpLogger.Error(LogSource, $"API call failed with status: {response.StatusCode}");
await Console.Error.WriteLineAsync($"[MCP] API call failed with status: {response.StatusCode}");
// Return sanitized error message to client // Return sanitized error message to client
var errorMessage = GetSanitizedHttpErrorMessage(response.StatusCode); var errorMessage = GetSanitizedHttpErrorMessage(response.StatusCode);
@ -99,24 +102,21 @@ public class McpHttpClientService : ITransientDependency
} }
catch (HttpRequestException ex) catch (HttpRequestException ex)
{ {
// Log detailed error to stderr for debugging _mcpLogger.Error(LogSource, $"Network error calling tool '{toolName}'", ex);
await Console.Error.WriteLineAsync($"[MCP] Network error calling tool '{toolName}': {ex.Message}");
// Return sanitized error to client // Return sanitized error to client
return CreateErrorResponse(ErrorMessages.NetworkConnectivity); return CreateErrorResponse(ErrorMessages.NetworkConnectivity);
} }
catch (TaskCanceledException ex) catch (TaskCanceledException ex)
{ {
// Log detailed error to stderr for debugging _mcpLogger.Error(LogSource, $"Timeout calling tool '{toolName}'", ex);
await Console.Error.WriteLineAsync($"[MCP] Timeout calling tool '{toolName}': {ex.Message}");
// Return sanitized error to client // Return sanitized error to client
return CreateErrorResponse(ErrorMessages.Timeout); return CreateErrorResponse(ErrorMessages.Timeout);
} }
catch (Exception ex) catch (Exception ex)
{ {
// Log detailed error to stderr for debugging _mcpLogger.Error(LogSource, $"Unexpected error calling tool '{toolName}'", ex);
await Console.Error.WriteLineAsync($"[MCP] Unexpected error calling tool '{toolName}': {ex.Message}");
// Return generic sanitized error to client // Return generic sanitized error to client
return CreateErrorResponse(ErrorMessages.Unexpected); return CreateErrorResponse(ErrorMessages.Unexpected);
@ -152,7 +152,7 @@ public class McpHttpClientService : ITransientDependency
HttpStatusCode.Forbidden => "Access denied. You do not have permission to use this tool.", 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.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.BadRequest => "The tool request was invalid. Please check your input parameters and try again.",
HttpStatusCode.TooManyRequests => "Rate limit exceeded. Please wait a moment before trying 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.ServiceUnavailable => "The service is temporarily unavailable. Please try again later.",
HttpStatusCode.InternalServerError => "The tool execution encountered an internal error. 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." _ => "The tool execution failed. Please try again later."
@ -188,8 +188,7 @@ public class McpHttpClientService : ITransientDependency
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
// Log detailed error to stderr for debugging _mcpLogger.Error(LogSource, $"Failed to fetch tool definitions with status: {response.StatusCode}");
await Console.Error.WriteLineAsync($"[MCP] Failed to fetch tool definitions with status: {response.StatusCode}");
// Throw sanitized exception // Throw sanitized exception
var errorMessage = GetSanitizedHttpErrorMessage(response.StatusCode); var errorMessage = GetSanitizedHttpErrorMessage(response.StatusCode);
@ -205,24 +204,21 @@ public class McpHttpClientService : ITransientDependency
} }
catch (HttpRequestException ex) catch (HttpRequestException ex)
{ {
// Log detailed error to stderr for debugging _mcpLogger.Error(LogSource, "Network error fetching tool definitions", ex);
await Console.Error.WriteLineAsync($"[MCP] Network error fetching tool definitions: {ex.Message}");
// Throw sanitized exception // Throw sanitized exception
throw CreateToolDefinitionException("Network connectivity issue. Please check your internet connection and try again."); throw CreateToolDefinitionException("Network connectivity issue. Please check your internet connection and try again.");
} }
catch (TaskCanceledException ex) catch (TaskCanceledException ex)
{ {
// Log detailed error to stderr for debugging _mcpLogger.Error(LogSource, "Timeout fetching tool definitions", ex);
await Console.Error.WriteLineAsync($"[MCP] Timeout fetching tool definitions: {ex.Message}");
// Throw sanitized exception // Throw sanitized exception
throw CreateToolDefinitionException("Request timed out. Please try again."); throw CreateToolDefinitionException("Request timed out. Please try again.");
} }
catch (JsonException ex) catch (JsonException ex)
{ {
// Log detailed error to stderr for debugging _mcpLogger.Error(LogSource, "JSON parsing error", ex);
await Console.Error.WriteLineAsync($"[MCP] JSON parsing error: {ex.Message}");
// Throw sanitized exception // Throw sanitized exception
throw CreateToolDefinitionException("Invalid response format received."); throw CreateToolDefinitionException("Invalid response format received.");
@ -234,8 +230,7 @@ public class McpHttpClientService : ITransientDependency
} }
catch (Exception ex) catch (Exception ex)
{ {
// Log detailed error to stderr for debugging _mcpLogger.Error(LogSource, "Unexpected error fetching tool definitions", ex);
await Console.Error.WriteLineAsync($"[MCP] Unexpected error fetching tool definitions: {ex.Message}");
// Throw sanitized exception // Throw sanitized exception
throw CreateToolDefinitionException("An unexpected error occurred. Please try again later."); throw CreateToolDefinitionException("An unexpected error occurred. Please try again later.");

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

@ -0,0 +1,210 @@
using System;
using System.IO;
using System.Text;
using Volo.Abp.DependencyInjection;
namespace Volo.Abp.Cli.Commands.Services;
/// <summary>
/// MCP logger implementation that writes to both file and stderr.
/// - All logs at or above the configured level are written to file
/// - 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 long MaxLogFileSizeBytes = 5 * 1024 * 1024; // 5MB
private const string LogPrefix = "[MCP]";
private readonly object _fileLock = new();
private readonly McpLogLevel _configuredLogLevel;
public McpLogger()
{
_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)
{
return;
}
if (level < _configuredLogLevel)
{
return;
}
var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
var levelStr = level.ToString().ToUpperInvariant();
var formattedMessage = $"[{timestamp}][{levelStr}][{source}] {message}";
// Write to file (all levels at or above configured level)
WriteToFile(formattedMessage);
// Write to stderr for Warning and Error levels
if (level >= McpLogLevel.Warning)
{
WriteToStderr(levelStr, message);
}
}
private void WriteToFile(string formattedMessage)
{
try
{
lock (_fileLock)
{
EnsureLogDirectoryExists();
RotateLogFileIfNeeded();
File.AppendAllText(
CliPaths.McpLog,
formattedMessage + Environment.NewLine,
Encoding.UTF8);
}
}
catch
{
// Silently ignore file write errors to not disrupt MCP operations
}
}
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 void EnsureLogDirectoryExists()
{
var directory = Path.GetDirectoryName(CliPaths.McpLog);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
}
private void RotateLogFileIfNeeded()
{
try
{
if (!File.Exists(CliPaths.McpLog))
{
return;
}
var fileInfo = new FileInfo(CliPaths.McpLog);
if (fileInfo.Length < MaxLogFileSizeBytes)
{
return;
}
var backupPath = CliPaths.McpLog + ".1";
// Delete old backup if exists
if (File.Exists(backupPath))
{
File.Delete(backupPath);
}
// Rename current log to backup
File.Move(CliPaths.McpLog, backupPath);
}
catch
{
// Silently ignore rotation errors
}
}
private static McpLogLevel GetConfiguredLogLevel()
{
#if DEBUG
// In development builds, allow full control via environment variable
var envValue = Environment.GetEnvironmentVariable(CliConsts.McpLogLevelEnvironmentVariable);
if (string.IsNullOrWhiteSpace(envValue))
{
return McpLogLevel.Info; // Default level
}
return envValue.ToLowerInvariant() switch
{
"debug" => McpLogLevel.Debug,
"info" => McpLogLevel.Info,
"warning" => McpLogLevel.Warning,
"error" => McpLogLevel.Error,
"none" => McpLogLevel.None,
_ => McpLogLevel.Info
};
#else
// In release builds, restrict to Warning or higher (ignore env variable for Debug/Info)
var envValue = Environment.GetEnvironmentVariable(CliConsts.McpLogLevelEnvironmentVariable);
if (string.IsNullOrWhiteSpace(envValue))
{
return McpLogLevel.Info; // Default level
}
return envValue.ToLowerInvariant() switch
{
"debug" => McpLogLevel.Info, // Cap Debug to Info
"info" => McpLogLevel.Info,
"warning" => McpLogLevel.Warning,
"error" => McpLogLevel.Error,
"none" => McpLogLevel.None,
_ => McpLogLevel.Info
};
#endif
}
}
/// <summary>
/// Log levels for MCP logging.
/// </summary>
public enum McpLogLevel
{
Debug = 0,
Info = 1,
Warning = 2,
Error = 3,
None = 4
}

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

@ -4,7 +4,6 @@ using System.Linq;
using System.Text.Json; using System.Text.Json;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using ModelContextProtocol.Protocol; using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server; using ModelContextProtocol.Server;
@ -15,6 +14,8 @@ namespace Volo.Abp.Cli.Commands.Services;
public class McpServerService : ITransientDependency public class McpServerService : ITransientDependency
{ {
private const string LogSource = nameof(McpServerService);
private static class ToolErrorMessages private static class ToolErrorMessages
{ {
public const string InvalidResponseFormat = "The tool execution completed but returned an invalid response format. Please try again."; public const string InvalidResponseFormat = "The tool execution completed but returned an invalid response format. Please try again.";
@ -23,26 +24,28 @@ public class McpServerService : ITransientDependency
private readonly McpHttpClientService _mcpHttpClient; private readonly McpHttpClientService _mcpHttpClient;
private readonly McpToolsCacheService _toolsCacheService; private readonly McpToolsCacheService _toolsCacheService;
private readonly IMcpLogger _mcpLogger;
public McpServerService( public McpServerService(
McpHttpClientService mcpHttpClient, McpHttpClientService mcpHttpClient,
McpToolsCacheService toolsCacheService) McpToolsCacheService toolsCacheService,
IMcpLogger mcpLogger)
{ {
_mcpHttpClient = mcpHttpClient; _mcpHttpClient = mcpHttpClient;
_toolsCacheService = toolsCacheService; _toolsCacheService = toolsCacheService;
_mcpLogger = mcpLogger;
} }
public async Task RunAsync(CancellationToken cancellationToken = default) public async Task RunAsync(CancellationToken cancellationToken = default)
{ {
// Log to stderr to avoid corrupting stdout JSON-RPC stream _mcpLogger.Info(LogSource, "Starting ABP MCP Server (stdio)");
await Console.Error.WriteLineAsync("[MCP] Starting ABP MCP Server (stdio)");
var options = new McpServerOptions(); var options = new McpServerOptions();
await RegisterAllToolsAsync(options); await RegisterAllToolsAsync(options);
// Use NullLoggerFactory to prevent ModelContextProtocol library from logging to stdout // Use NullLoggerFactory to prevent ModelContextProtocol library from logging to stdout
// All our logging goes to stderr via Console.Error // All our logging goes to file and stderr via IMcpLogger
var server = McpServer.Create( var server = McpServer.Create(
new StdioServerTransport("abp-mcp-server", NullLoggerFactory.Instance), new StdioServerTransport("abp-mcp-server", NullLoggerFactory.Instance),
options options
@ -50,7 +53,7 @@ public class McpServerService : ITransientDependency
await server.RunAsync(cancellationToken); await server.RunAsync(cancellationToken);
await Console.Error.WriteLineAsync("[MCP] ABP MCP Server stopped"); _mcpLogger.Info(LogSource, "ABP MCP Server stopped");
} }
private async Task RegisterAllToolsAsync(McpServerOptions options) private async Task RegisterAllToolsAsync(McpServerOptions options)
@ -58,7 +61,7 @@ public class McpServerService : ITransientDependency
// Get tool definitions from cache (or fetch from server) // Get tool definitions from cache (or fetch from server)
var toolDefinitions = await _toolsCacheService.GetToolDefinitionsAsync(); var toolDefinitions = await _toolsCacheService.GetToolDefinitionsAsync();
await Console.Error.WriteLineAsync($"[MCP] Registering {toolDefinitions.Count} tools"); _mcpLogger.Info(LogSource, $"Registering {toolDefinitions.Count} tools");
// Register each tool dynamically // Register each tool dynamically
foreach (var toolDef in toolDefinitions) foreach (var toolDef in toolDefinitions)
@ -129,8 +132,7 @@ public class McpServerService : ITransientDependency
JsonSerializer.SerializeToElement(inputSchema), JsonSerializer.SerializeToElement(inputSchema),
async (context, cancellationToken) => async (context, cancellationToken) =>
{ {
// Log to stderr to avoid corrupting stdout JSON-RPC stream _mcpLogger.Debug(LogSource, $"Tool '{name}' called with arguments: {context.Params.Arguments}");
await Console.Error.WriteLineAsync($"[MCP] Tool '{name}' called with arguments: {context.Params.Arguments}");
try try
{ {
@ -151,11 +153,11 @@ public class McpServerService : ITransientDependency
// Check if the HTTP client returned an error // Check if the HTTP client returned an error
if (callToolResult.IsError == true) if (callToolResult.IsError == true)
{ {
await Console.Error.WriteLineAsync($"[MCP] Tool '{name}' returned an error"); _mcpLogger.Warning(LogSource, $"Tool '{name}' returned an error");
} }
else else
{ {
await Console.Error.WriteLineAsync($"[MCP] Tool '{name}' executed successfully"); _mcpLogger.Debug(LogSource, $"Tool '{name}' executed successfully");
} }
return callToolResult; return callToolResult;
@ -163,8 +165,8 @@ public class McpServerService : ITransientDependency
} }
catch (Exception deserializeEx) catch (Exception deserializeEx)
{ {
await Console.Error.WriteLineAsync($"[MCP] Failed to deserialize response as CallToolResult: {deserializeEx.Message}"); _mcpLogger.Error(LogSource, $"Failed to deserialize response as CallToolResult: {deserializeEx.Message}");
await Console.Error.WriteLineAsync($"[MCP] Response was: {resultJson.Substring(0, Math.Min(500, resultJson.Length))}"); _mcpLogger.Debug(LogSource, $"Response was: {resultJson.Substring(0, Math.Min(500, resultJson.Length))}");
} }
// Fallback: return error result if deserialization fails // Fallback: return error result if deserialization fails
@ -173,7 +175,7 @@ public class McpServerService : ITransientDependency
catch (Exception ex) catch (Exception ex)
{ {
// Log detailed error for debugging // Log detailed error for debugging
await Console.Error.WriteLineAsync($"[MCP] Tool '{name}' execution failed with exception: {ex.Message}"); _mcpLogger.Error(LogSource, $"Tool '{name}' execution failed", ex);
// Return sanitized error to client // Return sanitized error to client
return CreateErrorResult(ToolErrorMessages.UnexpectedError); return CreateErrorResult(ToolErrorMessages.UnexpectedError);

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

@ -15,21 +15,26 @@ namespace Volo.Abp.Cli.Commands.Services;
public class McpToolsCacheService : ITransientDependency public class McpToolsCacheService : ITransientDependency
{ {
private const string LogSource = nameof(McpToolsCacheService);
private readonly McpHttpClientService _mcpHttpClient; private readonly McpHttpClientService _mcpHttpClient;
private readonly MemoryService _memoryService; private readonly MemoryService _memoryService;
private readonly McpToolDefinitionValidator _validator; private readonly McpToolDefinitionValidator _validator;
private readonly ILogger<McpToolsCacheService> _logger; private readonly ILogger<McpToolsCacheService> _logger;
private readonly IMcpLogger _mcpLogger;
public McpToolsCacheService( public McpToolsCacheService(
McpHttpClientService mcpHttpClient, McpHttpClientService mcpHttpClient,
MemoryService memoryService, MemoryService memoryService,
McpToolDefinitionValidator validator, McpToolDefinitionValidator validator,
ILogger<McpToolsCacheService> logger) ILogger<McpToolsCacheService> logger,
IMcpLogger mcpLogger)
{ {
_mcpHttpClient = mcpHttpClient; _mcpHttpClient = mcpHttpClient;
_memoryService = memoryService; _memoryService = memoryService;
_validator = validator; _validator = validator;
_logger = logger; _logger = logger;
_mcpLogger = mcpLogger;
} }
public async Task<List<McpToolDefinition>> GetToolDefinitionsAsync() public async Task<List<McpToolDefinition>> GetToolDefinitionsAsync()
@ -39,7 +44,7 @@ public class McpToolsCacheService : ITransientDependency
var cachedTools = await LoadFromCacheAsync(); var cachedTools = await LoadFromCacheAsync();
if (cachedTools != null) if (cachedTools != null)
{ {
await Console.Error.WriteLineAsync("[MCP] Using cached tool definitions"); _mcpLogger.Debug(LogSource, "Using cached tool definitions");
return cachedTools; return cachedTools;
} }
} }
@ -47,7 +52,7 @@ public class McpToolsCacheService : ITransientDependency
// Cache is invalid or missing, fetch from server // Cache is invalid or missing, fetch from server
try try
{ {
await Console.Error.WriteLineAsync("[MCP] Fetching tool definitions from server..."); _mcpLogger.Info(LogSource, "Fetching tool definitions from server...");
var tools = await _mcpHttpClient.GetToolDefinitionsAsync(); var tools = await _mcpHttpClient.GetToolDefinitionsAsync();
// Validate and filter tool definitions // Validate and filter tool definitions
@ -56,7 +61,7 @@ public class McpToolsCacheService : ITransientDependency
if (validTools.Count == 0) if (validTools.Count == 0)
{ {
_logger.LogWarning("No valid tool definitions received from server"); _logger.LogWarning("No valid tool definitions received from server");
await Console.Error.WriteLineAsync("[MCP] Warning: No valid tool definitions received from server"); _mcpLogger.Warning(LogSource, "No valid tool definitions received from server");
return new List<McpToolDefinition>(); return new List<McpToolDefinition>();
} }
@ -64,24 +69,24 @@ public class McpToolsCacheService : ITransientDependency
await SaveToCacheAsync(validTools); await SaveToCacheAsync(validTools);
await _memoryService.SetAsync(CliConsts.MemoryKeys.McpToolsLastFetchDate, DateTime.Now.ToString(CultureInfo.InvariantCulture)); await _memoryService.SetAsync(CliConsts.MemoryKeys.McpToolsLastFetchDate, DateTime.Now.ToString(CultureInfo.InvariantCulture));
await Console.Error.WriteLineAsync($"[MCP] Successfully fetched and cached {validTools.Count} tool definitions"); _mcpLogger.Info(LogSource, $"Successfully fetched and cached {validTools.Count} tool definitions");
return validTools; return validTools;
} }
catch (Exception ex) catch (Exception ex)
{ {
// Sanitize error message - use generic message for logger // Sanitize error message - use generic message for logger
_logger.LogWarning("Failed to fetch tool definitions from server"); _logger.LogWarning("Failed to fetch tool definitions from server");
await Console.Error.WriteLineAsync($"[MCP] Failed to fetch from server, attempting to use cached data..."); _mcpLogger.Warning(LogSource, "Failed to fetch from server, attempting to use cached data...");
// Fall back to cache even if expired // Fall back to cache even if expired
var cachedTools = await LoadFromCacheAsync(); var cachedTools = await LoadFromCacheAsync();
if (cachedTools != null) if (cachedTools != null)
{ {
await Console.Error.WriteLineAsync("[MCP] Using expired cache as fallback"); _mcpLogger.Info(LogSource, "Using expired cache as fallback");
return cachedTools; return cachedTools;
} }
await Console.Error.WriteLineAsync("[MCP] No cached data available, using empty tool list"); _mcpLogger.Warning(LogSource, "No cached data available, using empty tool list");
return new List<McpToolDefinition>(); return new List<McpToolDefinition>();
} }
} }

Loading…
Cancel
Save