Browse Source

Refactor MCP command handling and tool definition flow

Introduces CommandLineArgsExtensions for MCP command detection, removes McpToolDefinitionValidator, and updates MCP tool definition fetching to require a successful server connection. Cleans up unused environment variables, adds mcp-config.json support, and simplifies tool validation and caching logic. These changes improve reliability and maintainability of MCP command execution and tool management.
pull/24677/head
Mansur Besleney 3 weeks ago
parent
commit
d63719fb14
  1. 11
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Args/CommandLineArgsExtensions.cs
  2. 2
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/CliConsts.cs
  3. 1
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/CliPaths.cs
  4. 6
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/CliService.cs
  5. 4
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/CommandSelector.cs
  6. 4
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/HelpCommand.cs
  7. 80
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/McpCommand.cs
  8. 68
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpHttpClientService.cs
  9. 1
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpServerService.cs
  10. 181
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpToolDefinitionValidator.cs
  11. 55
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpToolsCacheService.cs

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

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

@ -20,7 +20,6 @@ public static class CliConsts
public static string AppSettingsSecretJsonFileName = "appsettings.secrets.json";
public const string McpServerUrlEnvironmentVariable = "ABP_MCP_SERVER_URL";
public const string McpLogLevelEnvironmentVariable = "ABP_MCP_LOG_LEVEL";
public const string DefaultMcpServerUrl = "https://mcp.abp.io";
@ -28,6 +27,5 @@ public static class CliConsts
{
public const string LatestCliVersionCheckDate = "LatestCliVersionCheckDate";
public const string McpToolsLastFetchDate = "McpToolsLastFetchDate";
public const string McpServerUrl = "McpServerUrl";
}
}

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

@ -16,6 +16,7 @@ public static class CliPaths
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");
}

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

@ -64,7 +64,7 @@ public class CliService : ITransientDependency
var commandLineArgs = CommandLineArgumentParser.Parse(args);
var currentCliVersion = await CliVersionService.GetCurrentCliVersionAsync();
var isMcpCommand = commandLineArgs.IsCommand("mcp");
var isMcpCommand = commandLineArgs.IsMcpCommand();
// Don't print banner for MCP command to avoid corrupting stdout JSON-RPC stream
if (!isMcpCommand)
@ -99,7 +99,7 @@ public class CliService : ITransientDependency
catch (CliUsageException usageException)
{
// For MCP command, use IMcpLogger to avoid corrupting stdout JSON-RPC stream
if (commandLineArgs.IsCommand("mcp"))
if (commandLineArgs.IsMcpCommand())
{
_mcpLogger.Error(McpLogSource, usageException.Message);
}
@ -113,7 +113,7 @@ public class CliService : ITransientDependency
{
await _telemetryService.AddErrorActivityAsync(ex.Message);
// For MCP command, use IMcpLogger to avoid corrupting stdout JSON-RPC stream
if (commandLineArgs.IsCommand("mcp"))
if (commandLineArgs.IsMcpCommand())
{
_mcpLogger.Error(McpLogSource, "Fatal error", ex);
}

4
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;
@ -18,7 +18,7 @@ public class CommandSelector : ICommandSelector, ITransientDependency
public Type Select(CommandLineArgs commandLineArgs)
{
// Don't fall back to HelpCommand for MCP command to avoid corrupting stdout JSON-RPC stream
if (commandLineArgs.IsCommand("mcp"))
if (commandLineArgs.IsMcpCommand())
{
return Options.Commands.GetOrDefault("mcp") ?? typeof(HelpCommand);
}

4
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;
@ -34,7 +34,7 @@ public class HelpCommand : IConsoleCommand, ITransientDependency
{
// Don't output help text for MCP command to avoid corrupting stdout JSON-RPC stream
// If MCP command is being used, it should have been handled directly, not through HelpCommand
if (commandLineArgs.IsCommand("mcp"))
if (commandLineArgs.IsMcpCommand())
{
// Silently return - MCP server should handle its own errors
return Task.CompletedTask;

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

@ -65,14 +65,16 @@ public class McpCommand : IConsoleCommand, ITransientDependency
await using var _ = _telemetryService.TrackActivityAsync(ActivityNameConsts.AbpCliCommandsMcp);
// Check server health before starting
// 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)
{
_mcpLogger.Warning(LogSource, "Could not connect to ABP.IO MCP Server. The server might be offline.");
_mcpLogger.Info(LogSource, "Continuing to start local MCP server...");
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...");
@ -141,15 +143,13 @@ public class McpCommand : IConsoleCommand, ITransientDependency
private Task PrintConfigurationAsync()
{
var abpCliPath = GetAbpCliExecutablePath();
var config = new McpClientConfiguration
{
McpServers = new Dictionary<string, McpServerConfig>
{
["abp"] = new McpServerConfig
{
Command = abpCliPath,
Command = "abp",
Args = new List<string> { "mcp" },
Env = new Dictionary<string, string>()
}
@ -167,74 +167,6 @@ public class McpCommand : IConsoleCommand, ITransientDependency
return Task.CompletedTask;
}
private string GetAbpCliExecutablePath()
{
var processPath = TryGetExecutablePathFromCurrentProcess();
if (processPath != null)
{
return processPath;
}
var environmentPath = TryGetExecutablePathFromEnvironmentPath();
if (environmentPath != null)
{
return environmentPath;
}
// Default to "abp" and let the system resolve it
return "abp";
}
private string TryGetExecutablePathFromCurrentProcess()
{
try
{
using (var process = Process.GetCurrentProcess())
{
var processPath = process.MainModule?.FileName;
if (!string.IsNullOrEmpty(processPath) &&
Path.GetFileName(processPath).StartsWith("abp", StringComparison.OrdinalIgnoreCase))
{
return processPath;
}
}
}
catch
{
// Ignore errors getting process path
}
return null;
}
private string TryGetExecutablePathFromEnvironmentPath()
{
var pathEnv = Environment.GetEnvironmentVariable("PATH");
if (string.IsNullOrEmpty(pathEnv))
{
return null;
}
var paths = pathEnv.Split(Path.PathSeparator);
foreach (var path in paths)
{
var abpPath = Path.Combine(path, "abp.exe");
if (File.Exists(abpPath))
{
return abpPath;
}
abpPath = Path.Combine(path, "abp");
if (File.Exists(abpPath))
{
return abpPath;
}
}
return null;
}
public string GetUsageInfo()
{
var sb = new StringBuilder();

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

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
@ -9,15 +10,13 @@ using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Volo.Abp.Cli.Commands.Models;
using Volo.Abp.Cli.Http;
using Volo.Abp.Cli.Memory;
using Volo.Abp.DependencyInjection;
using Volo.Abp.IO;
namespace Volo.Abp.Cli.Commands.Services;
public class McpHttpClientService : ITransientDependency
{
private const string LogSource = nameof(McpHttpClientService);
private static readonly JsonSerializerOptions JsonSerializerOptionsWeb = new(JsonSerializerDefaults.Web);
private static class ErrorMessages
@ -27,53 +26,67 @@ public class McpHttpClientService : ITransientDependency
public const string Unexpected = "The tool execution failed due to an unexpected error. Please try again later.";
}
private const string LogSource = nameof(McpHttpClientService);
private readonly CliHttpClientFactory _httpClientFactory;
private readonly ILogger<McpHttpClientService> _logger;
private readonly IMcpLogger _mcpLogger;
private readonly MemoryService _memoryService;
private readonly Lazy<Task<string>> _cachedServerUrlLazy;
private List<string> _validToolNames;
private bool _toolDefinitionsLoaded;
public McpHttpClientService(
CliHttpClientFactory httpClientFactory,
ILogger<McpHttpClientService> logger,
IMcpLogger mcpLogger,
MemoryService memoryService)
IMcpLogger mcpLogger)
{
_httpClientFactory = httpClientFactory;
_logger = logger;
_mcpLogger = mcpLogger;
_memoryService = memoryService;
_cachedServerUrlLazy = new Lazy<Task<string>>(GetMcpServerUrlInternalAsync);
}
private async Task<string> GetMcpServerUrlAsync()
{
return await _cachedServerUrlLazy.Value;
return "http://localhost:5100";//await _cachedServerUrlLazy.Value;
}
private async Task<string> GetMcpServerUrlInternalAsync()
{
// 1. Check environment variable (highest priority)
var envUrl = Environment.GetEnvironmentVariable(CliConsts.McpServerUrlEnvironmentVariable);
if (!string.IsNullOrWhiteSpace(envUrl))
{
return envUrl.TrimEnd('/');
}
// 2. Check persisted setting
var persistedUrl = await _memoryService.GetAsync(CliConsts.MemoryKeys.McpServerUrl);
if (!string.IsNullOrWhiteSpace(persistedUrl))
// Check config file
if (File.Exists(CliPaths.McpConfig))
{
return persistedUrl.TrimEnd('/');
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");
}
}
// 3. Return default
// Return default
return CliConsts.DefaultMcpServerUrl;
}
private class McpConfig
{
public string ServerUrl { get; set; }
}
public async Task<string> CallToolAsync(string toolName, JsonElement arguments)
{
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))
{
@ -146,16 +159,6 @@ public class McpHttpClientService : ITransientDependency
}, JsonSerializerOptionsWeb);
}
private CliUsageException CreateToolDefinitionException(string userMessage)
{
return new CliUsageException($"Failed to fetch tool definitions: {userMessage}");
}
private CliUsageException CreateToolDefinitionException(string userMessage, Exception innerException)
{
return new CliUsageException($"Failed to fetch tool definitions: {userMessage}", innerException);
}
private string GetSanitizedHttpErrorMessage(HttpStatusCode statusCode)
{
return statusCode switch
@ -204,7 +207,7 @@ public class McpHttpClientService : ITransientDependency
// Throw sanitized exception
var errorMessage = GetSanitizedHttpErrorMessage(response.StatusCode);
throw CreateToolDefinitionException(errorMessage);
throw new CliUsageException($"Failed to fetch tool definitions: {errorMessage}");
}
var responseContent = await response.Content.ReadAsStringAsync();
@ -215,6 +218,7 @@ public class McpHttpClientService : ITransientDependency
// Cache tool names for validation
_validToolNames = tools.Select(t => t.Name).ToList();
_toolDefinitionsLoaded = true;
return tools;
}
@ -253,7 +257,7 @@ public class McpHttpClientService : ITransientDependency
_ => "An unexpected error occurred. Please try again later."
};
return CreateToolDefinitionException(userMessage, ex);
return new CliUsageException($"Failed to fetch tool definitions: {userMessage}", ex);
}
private class McpToolsResponse

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

@ -4,6 +4,7 @@ using System.Linq;
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;

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

@ -1,181 +0,0 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using Volo.Abp.Cli.Commands.Models;
using Volo.Abp.DependencyInjection;
namespace Volo.Abp.Cli.Commands.Services;
public class McpToolDefinitionValidator : ITransientDependency
{
private const int MaxToolNameLength = 100;
private const int MaxDescriptionLength = 2000;
private const string UnknownToolName = "<unknown>";
private static readonly Regex ToolNameRegex = new Regex("^[a-zA-Z0-9_]+$", RegexOptions.Compiled);
private static readonly HashSet<string> ValidTypeValues = new HashSet<string>
{ "string", "number", "boolean", "object", "array" };
private readonly ILogger<McpToolDefinitionValidator> _logger;
public McpToolDefinitionValidator(ILogger<McpToolDefinitionValidator> logger)
{
_logger = logger;
}
public List<McpToolDefinition> ValidateAndFilter(List<McpToolDefinition> tools)
{
if (tools == null || tools.Count == 0)
{
return new List<McpToolDefinition>();
}
var validTools = new List<McpToolDefinition>();
foreach (var tool in tools)
{
try
{
if (!IsValidTool(tool))
{
continue;
}
validTools.Add(tool);
}
catch (Exception ex)
{
_logger.LogWarning($"Error validating tool '{tool?.Name ?? UnknownToolName}': {ex.Message}");
}
}
if (validTools.Count < tools.Count)
{
_logger.LogWarning($"Filtered out {tools.Count - validTools.Count} invalid tool(s). {validTools.Count} valid tool(s) remaining.");
}
return validTools;
}
private bool IsValidTool(McpToolDefinition tool)
{
if (!IsValidToolName(tool))
{
return false;
}
if (!IsValidDescription(tool))
{
return false;
}
if (tool.InputSchema != null && !IsValidInputSchema(tool))
{
return false;
}
return true;
}
private bool IsValidToolName(McpToolDefinition tool)
{
if (string.IsNullOrWhiteSpace(tool.Name))
{
_logger.LogWarning($"Skipping tool with empty name");
return false;
}
if (tool.Name.Length > MaxToolNameLength)
{
_logger.LogWarning($"Skipping tool '{tool.Name}' with name exceeding {MaxToolNameLength} characters");
return false;
}
if (!ToolNameRegex.IsMatch(tool.Name))
{
_logger.LogWarning($"Skipping tool with invalid name format: {tool.Name}");
return false;
}
return true;
}
private bool IsValidDescription(McpToolDefinition tool)
{
if (string.IsNullOrWhiteSpace(tool.Description))
{
_logger.LogWarning($"Skipping tool '{tool.Name}' with empty description");
return false;
}
if (tool.Description.Length > MaxDescriptionLength)
{
_logger.LogWarning($"Skipping tool '{tool.Name}' with description exceeding {MaxDescriptionLength} characters");
return false;
}
return true;
}
private bool IsValidInputSchema(McpToolDefinition tool)
{
if (!ArePropertiesValid(tool))
{
return false;
}
if (!AreRequiredFieldsValid(tool))
{
return false;
}
return true;
}
private bool ArePropertiesValid(McpToolDefinition tool)
{
if (tool.InputSchema.Properties == null)
{
return true;
}
foreach (var property in tool.InputSchema.Properties)
{
if (string.IsNullOrWhiteSpace(property.Value?.Type) ||
!ValidTypeValues.Contains(property.Value.Type))
{
_logger.LogWarning($"Skipping tool '{tool.Name}' with invalid property type: {property.Value?.Type ?? UnknownToolName}");
return false;
}
if (property.Value.Description != null && property.Value.Description.Length > MaxDescriptionLength)
{
_logger.LogWarning($"Skipping tool '{tool.Name}' with property description exceeding {MaxDescriptionLength} characters");
return false;
}
}
return true;
}
private bool AreRequiredFieldsValid(McpToolDefinition tool)
{
if (tool.InputSchema.Required == null || tool.InputSchema.Properties == null)
{
return true;
}
foreach (var required in tool.InputSchema.Required)
{
if (!tool.InputSchema.Properties.ContainsKey(required))
{
_logger.LogWarning($"Skipping tool '{tool.Name}' with required field '{required}' not in properties");
return false;
}
}
return true;
}
}

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

@ -20,20 +20,17 @@ public class McpToolsCacheService : ITransientDependency
private readonly McpHttpClientService _mcpHttpClient;
private readonly MemoryService _memoryService;
private readonly McpToolDefinitionValidator _validator;
private readonly ILogger<McpToolsCacheService> _logger;
private readonly IMcpLogger _mcpLogger;
public McpToolsCacheService(
McpHttpClientService mcpHttpClient,
MemoryService memoryService,
McpToolDefinitionValidator validator,
ILogger<McpToolsCacheService> logger,
IMcpLogger mcpLogger)
{
_mcpHttpClient = mcpHttpClient;
_memoryService = memoryService;
_validator = validator;
_logger = logger;
_mcpLogger = mcpLogger;
}
@ -51,45 +48,23 @@ public class McpToolsCacheService : ITransientDependency
}
// Cache is invalid or missing, fetch from server
try
_mcpLogger.Info(LogSource, "Fetching tool definitions from server...");
var tools = await _mcpHttpClient.GetToolDefinitionsAsync();
// Validate that we got tools
if (tools == null || tools.Count == 0)
{
_mcpLogger.Info(LogSource, "Fetching tool definitions from server...");
var tools = await _mcpHttpClient.GetToolDefinitionsAsync();
// Validate and filter tool definitions
var validTools = _validator.ValidateAndFilter(tools);
if (validTools.Count == 0)
{
_logger.LogWarning("No valid tool definitions received from server");
_mcpLogger.Warning(LogSource, "No valid tool definitions received from server");
return new List<McpToolDefinition>();
}
// Save validated tools to cache
await SaveToCacheAsync(validTools);
await _memoryService.SetAsync(CliConsts.MemoryKeys.McpToolsLastFetchDate, DateTime.Now.ToString(CultureInfo.InvariantCulture));
_mcpLogger.Info(LogSource, $"Successfully fetched and cached {validTools.Count} tool definitions");
return validTools;
}
catch (Exception ex)
{
// Sanitize error message - use generic message for logger
_logger.LogWarning("Failed to fetch tool definitions from server");
_mcpLogger.Warning(LogSource, "Failed to fetch from server, attempting to use cached data...");
// Fall back to cache even if expired
var cachedTools = await LoadFromCacheAsync();
if (cachedTools != null)
{
_mcpLogger.Info(LogSource, "Using expired cache as fallback");
return cachedTools;
}
_mcpLogger.Warning(LogSource, "No cached data available, using empty tool list");
return new List<McpToolDefinition>();
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()

Loading…
Cancel
Save