From 7821fc173bf5ddd392fa1d98e5eb901bd3df42de Mon Sep 17 00:00:00 2001 From: Mansur Besleney Date: Wed, 7 Jan 2026 12:05:31 +0300 Subject: [PATCH] Add dynamic MCP tool definition caching and registration Introduces McpToolDefinition model and McpToolsCacheService to fetch and cache tool definitions from the server, with fallback to cache if the server is unavailable. Updates McpServerService to dynamically register tools based on cached or fetched definitions, and adds related constants and paths. This enables more flexible and up-to-date tool management in the MCP server. --- .../Volo/Abp/Cli/CliConsts.cs | 1 + .../Volo/Abp/Cli/CliPaths.cs | 1 + .../Cli/Commands/Models/McpToolDefinition.cs | 23 +++ .../Commands/Services/McpHttpClientService.cs | 41 +++++ .../Cli/Commands/Services/McpServerService.cs | 150 +++++----------- .../Commands/Services/McpToolsCacheService.cs | 160 ++++++++++++++++++ 6 files changed, 266 insertions(+), 110 deletions(-) create mode 100644 framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Models/McpToolDefinition.cs create mode 100644 framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpToolsCacheService.cs diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/CliConsts.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/CliConsts.cs index 78a36fe329..27eb1274bf 100644 --- a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/CliConsts.cs +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/CliConsts.cs @@ -23,5 +23,6 @@ public static class CliConsts public static class MemoryKeys { public const string LatestCliVersionCheckDate = "LatestCliVersionCheckDate"; + public const string McpToolsLastFetchDate = "McpToolsLastFetchDate"; } } diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/CliPaths.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/CliPaths.cs index d47987b220..8ddf99a24e 100644 --- a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/CliPaths.cs +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/CliPaths.cs @@ -14,6 +14,7 @@ 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 readonly string AbpRootPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".abp"); } diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Models/McpToolDefinition.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Models/McpToolDefinition.cs new file mode 100644 index 0000000000..1c76a805b1 --- /dev/null +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Models/McpToolDefinition.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; + +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 class McpToolInputSchema +{ + public Dictionary Properties { get; set; } + public List Required { get; set; } +} + +public class McpToolProperty +{ + public string Type { get; set; } + public string Description { get; set; } +} + diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpHttpClientService.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpHttpClientService.cs index baa5dbbd97..985c96e6f0 100644 --- a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpHttpClientService.cs +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpHttpClientService.cs @@ -1,9 +1,11 @@ using System; +using System.Collections.Generic; 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; @@ -105,5 +107,44 @@ public class McpHttpClientService : ITransientDependency return false; } } + + public async Task> GetToolDefinitionsAsync(bool useLocalServer = false) + { + var baseUrl = LocalMcpServerUrl; //useLocalServer ? LocalMcpServerUrl : DefaultMcpServerUrl; + var url = $"{baseUrl}/tools"; + + try + { + using var httpClient = _httpClientFactory.CreateClient(needsAuthentication: true); + var response = await httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(); + await Console.Error.WriteLineAsync($"[MCP] Failed to fetch tool definitions: {response.StatusCode} - {errorContent}"); + throw new Exception($"Failed to fetch tool definitions: {response.StatusCode}"); + } + + var responseContent = await response.Content.ReadAsStringAsync(); + + // The API returns { tools: [...] } format + var result = JsonSerializer.Deserialize(responseContent, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + return result?.Tools ?? new List(); + } + catch (Exception ex) + { + await Console.Error.WriteLineAsync($"[MCP] Error fetching tool definitions: {ex.Message}"); + throw; + } + } + + private class McpToolsResponse + { + public List Tools { get; set; } + } } diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpServerService.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpServerService.cs index 0b03781466..02a5651ecb 100644 --- a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpServerService.cs +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpServerService.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -7,6 +8,7 @@ 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; @@ -14,11 +16,14 @@ namespace Volo.Abp.Cli.Commands.Services; public class McpServerService : ITransientDependency { private readonly McpHttpClientService _mcpHttpClient; + private readonly McpToolsCacheService _toolsCacheService; public McpServerService( - McpHttpClientService mcpHttpClient) + McpHttpClientService mcpHttpClient, + McpToolsCacheService toolsCacheService) { _mcpHttpClient = mcpHttpClient; + _toolsCacheService = toolsCacheService; } public async Task RunAsync(CancellationToken cancellationToken = default) @@ -28,7 +33,7 @@ public class McpServerService : ITransientDependency var options = new McpServerOptions(); - RegisterAllTools(options); + await RegisterAllToolsAsync(options); // Use NullLoggerFactory to prevent ModelContextProtocol library from logging to stdout // All our logging goes to stderr via Console.Error @@ -42,121 +47,46 @@ public class McpServerService : ITransientDependency await Console.Error.WriteLineAsync("[MCP] ABP MCP Server stopped"); } - private void RegisterAllTools(McpServerOptions options) + private async Task RegisterAllToolsAsync(McpServerOptions options) { - RegisterTool( - options, - "get_relevant_abp_documentation", - "Search ABP framework technical documentation including official guides, API references, and framework documentation.", - new - { - type = "object", - properties = new - { - query = new - { - type = "string", - description = "The search query to find relevant documentation" - } - }, - required = new[] { "query" } - } - ); + // Get tool definitions from cache (or fetch from server) + var toolDefinitions = await _toolsCacheService.GetToolDefinitionsAsync(); - RegisterTool( - options, - "get_relevant_abp_articles", - "Search ABP blog posts, tutorials, and community-contributed content.", - new - { - type = "object", - properties = new - { - query = new - { - type = "string", - description = "The search query to find relevant articles" - } - }, - required = new[] { "query" } - } - ); + await Console.Error.WriteLineAsync($"[MCP] Registering {toolDefinitions.Count} tools"); - RegisterTool( - options, - "get_relevant_abp_support_questions", - "Search support ticket history containing real-world problems and their solutions.", - new - { - type = "object", - properties = new - { - query = new - { - type = "string", - description = "The search query to find relevant support questions" - } - }, - required = new[] { "query" } - } - ); + // Register each tool dynamically + foreach (var toolDef in toolDefinitions) + { + RegisterToolFromDefinition(options, toolDef); + } + } - RegisterTool( - options, - "search_code", - "Search for code across ABP repositories using regex patterns.", - new - { - type = "object", - properties = new - { - query = new - { - type = "string", - description = "The regex pattern or search query to find code" - }, - repo_filter = new - { - type = "string", - description = "Optional repository filter to limit search scope" - } - }, - required = new[] { "query" } - } - ); + private void RegisterToolFromDefinition(McpServerOptions options, McpToolDefinition toolDef) + { + // Convert McpToolDefinition to the input schema format expected by MCP + var inputSchemaObject = new Dictionary + { + ["type"] = "object", + ["properties"] = ConvertProperties(toolDef.InputSchema?.Properties), + ["required"] = toolDef.InputSchema?.Required ?? new List() + }; - RegisterTool( - options, - "list_repos", - "List all available ABP repositories in SourceBot.", - new - { - type = "object", - properties = new { } - } - ); + RegisterTool(options, toolDef.Name, toolDef.Description, inputSchemaObject); + } + + private Dictionary ConvertProperties(Dictionary properties) + { + if (properties == null) + { + return new Dictionary(); + } - RegisterTool( - options, - "get_file_source", - "Retrieve the complete source code of a specific file from an ABP repository.", - new + return properties.ToDictionary( + kvp => kvp.Key, + kvp => (object)new Dictionary { - type = "object", - properties = new - { - repoId = new - { - type = "string", - description = "The repository identifier containing the file" - }, - fileName = new - { - type = "string", - description = "The file path or name to retrieve" - } - }, - required = new[] { "repoId", "fileName" } + ["type"] = kvp.Value.Type, + ["description"] = kvp.Value.Description } ); } diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpToolsCacheService.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpToolsCacheService.cs new file mode 100644 index 0000000000..3b2b664c7c --- /dev/null +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpToolsCacheService.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +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 readonly McpHttpClientService _mcpHttpClient; + private readonly MemoryService _memoryService; + private readonly ILogger _logger; + + public McpToolsCacheService( + McpHttpClientService mcpHttpClient, + MemoryService memoryService, + ILogger logger) + { + _mcpHttpClient = mcpHttpClient; + _memoryService = memoryService; + _logger = logger; + } + + public async Task> GetToolDefinitionsAsync() + { + if (await IsCacheValidAsync()) + { + var cachedTools = await LoadFromCacheAsync(); + if (cachedTools != null) + { + await Console.Error.WriteLineAsync("[MCP] Using cached tool definitions"); + return cachedTools; + } + } + + // Cache is invalid or missing, fetch from server + try + { + await Console.Error.WriteLineAsync("[MCP] Fetching tool definitions from server..."); + var tools = await _mcpHttpClient.GetToolDefinitionsAsync(); + + // Save to cache + await SaveToCacheAsync(tools); + await _memoryService.SetAsync(CliConsts.MemoryKeys.McpToolsLastFetchDate, DateTime.Now.ToString(CultureInfo.InvariantCulture)); + + await Console.Error.WriteLineAsync($"[MCP] Successfully fetched and cached {tools.Count} tool definitions"); + return tools; + } + catch (Exception ex) + { + _logger.LogWarning($"Failed to fetch tool definitions from server: {ex.Message}"); + await Console.Error.WriteLineAsync($"[MCP] Failed to fetch from server, attempting to use cached data..."); + + // Fall back to cache even if expired + var cachedTools = await LoadFromCacheAsync(); + if (cachedTools != null) + { + await Console.Error.WriteLineAsync("[MCP] Using expired cache as fallback"); + return cachedTools; + } + + await Console.Error.WriteLineAsync("[MCP] No cached data available, using empty tool list"); + return new List(); + } + } + + private async Task 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 24 hours old + if (DateTime.Now.Subtract(lastFetchTime).TotalHours < 24) + { + return true; + } + } + + return false; + } + catch (Exception ex) + { + _logger.LogWarning($"Error checking cache validity: {ex.Message}"); + return false; + } + } + + private async Task> LoadFromCacheAsync() + { + try + { + if (!File.Exists(CliPaths.McpToolsCache)) + { + return null; + } + + var json = await FileHelper.ReadAllTextAsync(CliPaths.McpToolsCache); + var tools = JsonSerializer.Deserialize>(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 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 + }); + + File.WriteAllText(CliPaths.McpToolsCache, json); + } + catch (Exception ex) + { + _logger.LogWarning($"Error saving tool definitions to cache: {ex.Message}"); + } + + return Task.CompletedTask; + } +} +