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