Browse Source

Add tool definition validation and improve MCP caching

Introduces McpToolDefinitionValidator to validate and filter tool definitions before caching. Updates McpHttpClientService to support configurable MCP server URLs and sanitizes error messages. Enhances McpToolsCacheService to use validated tools, adds restrictive file permissions for cache, and improves error handling and logging. Updates CliConsts with new constants for MCP server configuration.
pull/24677/head
Mansur Besleney 10 months ago
parent
commit
6f70ced612
  1. 4
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/CliConsts.cs
  2. 64
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpHttpClientService.cs
  3. 181
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpToolDefinitionValidator.cs
  4. 45
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpToolsCacheService.cs

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

@ -20,9 +20,13 @@ 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 DefaultMcpServerUrl = "https://mcp.abp.io";
public static class MemoryKeys public static class MemoryKeys
{ {
public const string LatestCliVersionCheckDate = "LatestCliVersionCheckDate"; public const string LatestCliVersionCheckDate = "LatestCliVersionCheckDate";
public const string McpToolsLastFetchDate = "McpToolsLastFetchDate"; public const string McpToolsLastFetchDate = "McpToolsLastFetchDate";
public const string McpServerUrl = "McpServerUrl";
} }
} }

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

@ -7,6 +7,7 @@ using System.Threading.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Volo.Abp.Cli.Commands.Models; using Volo.Abp.Cli.Commands.Models;
using Volo.Abp.Cli.Http; using Volo.Abp.Cli.Http;
using Volo.Abp.Cli.Memory;
using Volo.Abp.DependencyInjection; using Volo.Abp.DependencyInjection;
namespace Volo.Abp.Cli.Commands.Services; namespace Volo.Abp.Cli.Commands.Services;
@ -15,21 +16,51 @@ public class McpHttpClientService : ITransientDependency
{ {
private readonly CliHttpClientFactory _httpClientFactory; private readonly CliHttpClientFactory _httpClientFactory;
private readonly ILogger<McpHttpClientService> _logger; private readonly ILogger<McpHttpClientService> _logger;
private readonly MemoryService _memoryService;
private const string DefaultMcpServerUrl = "https://mcp.abp.io"; private string _cachedServerUrl;
private const string LocalMcpServerUrl = "http://localhost:5100";
public McpHttpClientService( public McpHttpClientService(
CliHttpClientFactory httpClientFactory, CliHttpClientFactory httpClientFactory,
ILogger<McpHttpClientService> logger) ILogger<McpHttpClientService> logger,
MemoryService memoryService)
{ {
_httpClientFactory = httpClientFactory; _httpClientFactory = httpClientFactory;
_logger = logger; _logger = logger;
_memoryService = memoryService;
} }
public async Task<string> CallToolAsync(string toolName, JsonElement arguments, bool useLocalServer = false) private async Task<string> GetMcpServerUrlAsync()
{ {
var baseUrl = LocalMcpServerUrl;//useLocalServer ? LocalMcpServerUrl : DefaultMcpServerUrl; // Return cached URL if already resolved
if (_cachedServerUrl != null)
{
return _cachedServerUrl;
}
// 1. Check environment variable (highest priority)
var envUrl = Environment.GetEnvironmentVariable(CliConsts.McpServerUrlEnvironmentVariable);
if (!string.IsNullOrWhiteSpace(envUrl))
{
_cachedServerUrl = envUrl.TrimEnd('/');
return _cachedServerUrl;
}
// 2. Check persisted setting
var persistedUrl = await _memoryService.GetAsync(CliConsts.MemoryKeys.McpServerUrl);
if (!string.IsNullOrWhiteSpace(persistedUrl))
{
_cachedServerUrl = persistedUrl.TrimEnd('/');
return _cachedServerUrl;
}
// 3. Return default
_cachedServerUrl = CliConsts.DefaultMcpServerUrl;
return _cachedServerUrl;
}
public async Task<string> CallToolAsync(string toolName, JsonElement arguments)
{
var baseUrl = "http://localhost:5100";//await GetMcpServerUrlAsync();
var url = $"{baseUrl}/tools/call"; var url = $"{baseUrl}/tools/call";
try try
@ -53,9 +84,8 @@ public class McpHttpClientService : ITransientDependency
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
var errorContent = await response.Content.ReadAsStringAsync(); // Log to stderr to avoid corrupting stdout - sanitize error message
// Log to stderr to avoid corrupting stdout await Console.Error.WriteLineAsync($"[MCP] API call failed with status: {response.StatusCode}");
await Console.Error.WriteLineAsync($"[MCP] API call failed: {response.StatusCode} - {errorContent}");
return JsonSerializer.Serialize(new return JsonSerializer.Serialize(new
{ {
@ -64,7 +94,7 @@ public class McpHttpClientService : ITransientDependency
new new
{ {
type = "text", type = "text",
text = $"Error: {response.StatusCode} - {errorContent}" text = $"Error: API call failed with status {response.StatusCode}"
} }
} }
}); });
@ -91,9 +121,9 @@ public class McpHttpClientService : ITransientDependency
} }
} }
public async Task<bool> CheckServerHealthAsync(bool useLocalServer = false) public async Task<bool> CheckServerHealthAsync()
{ {
var baseUrl = useLocalServer ? LocalMcpServerUrl : DefaultMcpServerUrl; var baseUrl = "http://localhost:5100";//await GetMcpServerUrlAsync();
try try
{ {
@ -108,9 +138,9 @@ public class McpHttpClientService : ITransientDependency
} }
} }
public async Task<List<McpToolDefinition>> GetToolDefinitionsAsync(bool useLocalServer = false) public async Task<List<McpToolDefinition>> GetToolDefinitionsAsync()
{ {
var baseUrl = LocalMcpServerUrl; //useLocalServer ? LocalMcpServerUrl : DefaultMcpServerUrl; var baseUrl = "http://localhost:5100";//await GetMcpServerUrlAsync();
var url = $"{baseUrl}/tools"; var url = $"{baseUrl}/tools";
try try
@ -120,9 +150,9 @@ public class McpHttpClientService : ITransientDependency
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
var errorContent = await response.Content.ReadAsStringAsync(); // Sanitize error message - don't expose server details
await Console.Error.WriteLineAsync($"[MCP] Failed to fetch tool definitions: {response.StatusCode} - {errorContent}"); await Console.Error.WriteLineAsync($"[MCP] Failed to fetch tool definitions with status: {response.StatusCode}");
throw new Exception($"Failed to fetch tool definitions: {response.StatusCode}"); throw new Exception($"Failed to fetch tool definitions with status: {response.StatusCode}");
} }
var responseContent = await response.Content.ReadAsStringAsync(); var responseContent = await response.Content.ReadAsStringAsync();

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

@ -0,0 +1,181 @@
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;
}
}

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

@ -16,15 +16,18 @@ public class McpToolsCacheService : ITransientDependency
{ {
private readonly McpHttpClientService _mcpHttpClient; private readonly McpHttpClientService _mcpHttpClient;
private readonly MemoryService _memoryService; private readonly MemoryService _memoryService;
private readonly McpToolDefinitionValidator _validator;
private readonly ILogger<McpToolsCacheService> _logger; private readonly ILogger<McpToolsCacheService> _logger;
public McpToolsCacheService( public McpToolsCacheService(
McpHttpClientService mcpHttpClient, McpHttpClientService mcpHttpClient,
MemoryService memoryService, MemoryService memoryService,
McpToolDefinitionValidator validator,
ILogger<McpToolsCacheService> logger) ILogger<McpToolsCacheService> logger)
{ {
_mcpHttpClient = mcpHttpClient; _mcpHttpClient = mcpHttpClient;
_memoryService = memoryService; _memoryService = memoryService;
_validator = validator;
_logger = logger; _logger = logger;
} }
@ -46,16 +49,27 @@ public class McpToolsCacheService : ITransientDependency
await Console.Error.WriteLineAsync("[MCP] Fetching tool definitions from server..."); await Console.Error.WriteLineAsync("[MCP] Fetching tool definitions from server...");
var tools = await _mcpHttpClient.GetToolDefinitionsAsync(); var tools = await _mcpHttpClient.GetToolDefinitionsAsync();
// Save to cache // Validate and filter tool definitions
await SaveToCacheAsync(tools); var validTools = _validator.ValidateAndFilter(tools);
if (validTools.Count == 0)
{
_logger.LogWarning("No valid tool definitions received from server");
await Console.Error.WriteLineAsync("[MCP] Warning: 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)); await _memoryService.SetAsync(CliConsts.MemoryKeys.McpToolsLastFetchDate, DateTime.Now.ToString(CultureInfo.InvariantCulture));
await Console.Error.WriteLineAsync($"[MCP] Successfully fetched and cached {tools.Count} tool definitions"); await Console.Error.WriteLineAsync($"[MCP] Successfully fetched and cached {validTools.Count} tool definitions");
return tools; return validTools;
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning($"Failed to fetch tool definitions from server: {ex.Message}"); // Sanitize error message - use generic message for logger
_logger.LogWarning("Failed to fetch tool definitions from server");
await Console.Error.WriteLineAsync($"[MCP] Failed to fetch from server, attempting to use cached data..."); await Console.Error.WriteLineAsync($"[MCP] Failed to fetch from server, attempting to use cached data...");
// Fall back to cache even if expired // Fall back to cache even if expired
@ -148,6 +162,9 @@ public class McpToolsCacheService : ITransientDependency
}); });
File.WriteAllText(CliPaths.McpToolsCache, json); File.WriteAllText(CliPaths.McpToolsCache, json);
// Set restrictive file permissions (user read/write only)
SetRestrictiveFilePermissions(CliPaths.McpToolsCache);
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -156,5 +173,23 @@ public class McpToolsCacheService : ITransientDependency
return Task.CompletedTask; return Task.CompletedTask;
} }
private void SetRestrictiveFilePermissions(string filePath)
{
try
{
// On Unix systems, set permissions to 600 (user read/write only)
if (!OperatingSystem.IsWindows())
{
File.SetUnixFileMode(filePath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
}
// On Windows, the file inherits permissions from the user profile directory,
// which is already restrictive to the current user
}
catch (Exception ex)
{
_logger.LogWarning($"Error setting file permissions: {ex.Message}");
}
}
} }

Loading…
Cancel
Save