From 5b8c1a48137bb196aeea51728fe421d74220bdfb Mon Sep 17 00:00:00 2001 From: Mansur Besleney Date: Mon, 29 Dec 2025 09:13:15 +0300 Subject: [PATCH 01/26] Add McpCommand to CLI core module Introduces the McpCommand for running a local MCP server and outputting client configuration for AI tool integration. Registers the new command in AbpCliCoreModule. --- .../Volo/Abp/Cli/AbpCliCoreModule.cs | 1 + .../Volo/Abp/Cli/Commands/McpCommand.cs | 76 +++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/McpCommand.cs diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/AbpCliCoreModule.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/AbpCliCoreModule.cs index 8ff8ad3206..a188137ea2 100644 --- a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/AbpCliCoreModule.cs +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/AbpCliCoreModule.cs @@ -79,6 +79,7 @@ public class AbpCliCoreModule : AbpModule options.Commands[ClearDownloadCacheCommand.Name] = typeof(ClearDownloadCacheCommand); options.Commands[RecreateInitialMigrationCommand.Name] = typeof(RecreateInitialMigrationCommand); options.Commands[GenerateRazorPage.Name] = typeof(GenerateRazorPage); + options.Commands[McpCommand.Name] = typeof(McpCommand); options.DisabledModulesToAddToSolution.Add("Volo.Abp.LeptonXTheme.Pro"); options.DisabledModulesToAddToSolution.Add("Volo.Abp.LeptonXTheme.Lite"); diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/McpCommand.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/McpCommand.cs new file mode 100644 index 0000000000..feb0b55e20 --- /dev/null +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/McpCommand.cs @@ -0,0 +1,76 @@ +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Volo.Abp.Cli.Args; +using Volo.Abp.Cli.Auth; +using Volo.Abp.Cli.Commands.Services; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.Cli.Commands; + +public class McpCommand : IConsoleCommand, ITransientDependency +{ + public const string Name = "mcp"; + + private readonly AuthService _authService; + private readonly AbpNuGetIndexUrlService _nuGetIndexUrlService; + + public ILogger Logger { get; set; } + + public McpCommand( + AbpNuGetIndexUrlService nuGetIndexUrlService, + AuthService authService, ILogger logger) + { + _nuGetIndexUrlService = nuGetIndexUrlService; + _authService = authService; + Logger = NullLogger.Instance; + } + + public async Task ExecuteAsync(CommandLineArgs commandLineArgs) + { + var loginInfo = await _authService.GetLoginInfoAsync(); + + if (string.IsNullOrEmpty(loginInfo?.Organization)) + { + throw new CliUsageException("Please log in with your account!"); + } + + var nugetIndexUrl = await _nuGetIndexUrlService.GetAsync(); + + if (nugetIndexUrl == null) + { + throw new CliUsageException("Could not find Nuget Index Url!"); + } + + Logger.LogInformation("Starting MCP server..."); + } + + public string GetUsageInfo() + { + var sb = new StringBuilder(); + + sb.AppendLine(""); + sb.AppendLine("Usage:"); + sb.AppendLine(""); + sb.AppendLine(" abp mcp [options]"); + sb.AppendLine(""); + sb.AppendLine("Options:"); + sb.AppendLine(""); + sb.AppendLine(" (start the local MCP server)"); + sb.AppendLine("getconfig (print MCP client configuration as JSON)"); + sb.AppendLine(""); + sb.AppendLine("Examples:"); + sb.AppendLine(""); + sb.AppendLine(" abp mcp"); + sb.AppendLine(" abp mcp getconfig"); + sb.AppendLine(""); + + return sb.ToString(); + } + + public static string GetShortDescription() + { + return "Runs the local MCP server and outputs client configuration for AI tool integration."; + } +} \ No newline at end of file From db11a81debed5112b88c5bf17894aeec84670ce1 Mon Sep 17 00:00:00 2001 From: Mansur Besleney Date: Mon, 29 Dec 2025 11:35:23 +0300 Subject: [PATCH 02/26] added mcp package --- Directory.Packages.props | 3 ++- framework/src/Volo.Abp.Cli.Core/Volo.Abp.Cli.Core.csproj | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 45b24e171e..5d05c21e02 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -30,6 +30,7 @@ + @@ -195,4 +196,4 @@ - + \ No newline at end of file diff --git a/framework/src/Volo.Abp.Cli.Core/Volo.Abp.Cli.Core.csproj b/framework/src/Volo.Abp.Cli.Core/Volo.Abp.Cli.Core.csproj index 078d1aa6cc..23cb5c2b33 100644 --- a/framework/src/Volo.Abp.Cli.Core/Volo.Abp.Cli.Core.csproj +++ b/framework/src/Volo.Abp.Cli.Core/Volo.Abp.Cli.Core.csproj @@ -14,6 +14,7 @@ + From 82552a6c1f1479ce255fff5edc08b651de75b61f Mon Sep 17 00:00:00 2001 From: Mansur Besleney Date: Mon, 29 Dec 2025 15:02:30 +0300 Subject: [PATCH 03/26] Add MCP server integration to ABP CLI Introduces a new MCP server mode to the ABP CLI, including a JSON-RPC server implementation, health checks, and configuration output. Adds supporting services for HTTP communication and configuration models, and updates the CLI to suppress the banner for MCP commands to avoid corrupting the JSON-RPC stream. --- .../Volo/Abp/Cli/CliService.cs | 10 +- .../Volo/Abp/Cli/Commands/McpCommand.cs | 152 +++++++- .../Commands/Models/McpClientConfiguration.cs | 23 ++ .../Commands/Services/McpHttpClientService.cs | 109 ++++++ .../Cli/Commands/Services/McpServerService.cs | 365 ++++++++++++++++++ 5 files changed, 654 insertions(+), 5 deletions(-) create mode 100644 framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Models/McpClientConfiguration.cs create mode 100644 framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpHttpClientService.cs create mode 100644 framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpServerService.cs diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/CliService.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/CliService.cs index 063ceddebf..fdc82c73fc 100644 --- a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/CliService.cs +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/CliService.cs @@ -55,10 +55,14 @@ public class CliService : ITransientDependency public async Task RunAsync(string[] args) { - var currentCliVersion = await CliVersionService.GetCurrentCliVersionAsync(); - Logger.LogInformation($"ABP CLI {currentCliVersion}"); - var commandLineArgs = CommandLineArgumentParser.Parse(args); + var currentCliVersion = await CliVersionService.GetCurrentCliVersionAsync(); + + // Don't print banner for MCP command to avoid corrupting stdout JSON-RPC stream + if (!commandLineArgs.IsCommand("mcp")) + { + Logger.LogInformation($"ABP CLI {currentCliVersion}"); + } #if !DEBUG if (!commandLineArgs.Options.ContainsKey("skip-cli-version-check")) diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/McpCommand.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/McpCommand.cs index feb0b55e20..4eeb3af0c1 100644 --- a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/McpCommand.cs +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/McpCommand.cs @@ -1,9 +1,17 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Reflection; using System.Text; +using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Volo.Abp.Cli.Args; using Volo.Abp.Cli.Auth; +using Volo.Abp.Cli.Commands.Models; using Volo.Abp.Cli.Commands.Services; using Volo.Abp.DependencyInjection; @@ -15,15 +23,21 @@ public class McpCommand : IConsoleCommand, ITransientDependency private readonly AuthService _authService; private readonly AbpNuGetIndexUrlService _nuGetIndexUrlService; + private readonly McpServerService _mcpServerService; + private readonly McpHttpClientService _mcpHttpClient; public ILogger Logger { get; set; } public McpCommand( AbpNuGetIndexUrlService nuGetIndexUrlService, - AuthService authService, ILogger logger) + AuthService authService, + McpServerService mcpServerService, + McpHttpClientService mcpHttpClient) { _nuGetIndexUrlService = nuGetIndexUrlService; _authService = authService; + _mcpServerService = mcpServerService; + _mcpHttpClient = mcpHttpClient; Logger = NullLogger.Instance; } @@ -42,8 +56,142 @@ public class McpCommand : IConsoleCommand, ITransientDependency { throw new CliUsageException("Could not find Nuget Index Url!"); } + + var option = commandLineArgs.Target; + + if (!string.IsNullOrEmpty(option) && option.Equals("getconfig", StringComparison.OrdinalIgnoreCase)) + { + await PrintConfigurationAsync(); + return; + } + + // Check server health before starting (log to stderr) + await Console.Error.WriteLineAsync("[MCP] Checking ABP.IO MCP Server connection..."); + var isHealthy = await _mcpHttpClient.CheckServerHealthAsync(); - Logger.LogInformation("Starting MCP server..."); + if (!isHealthy) + { + await Console.Error.WriteLineAsync("[MCP] Warning: Could not connect to ABP.IO MCP Server. The server might be offline."); + await Console.Error.WriteLineAsync("[MCP] Continuing to start local MCP server..."); + } + + await Console.Error.WriteLineAsync("[MCP] Starting ABP MCP Server..."); + + var cts = new CancellationTokenSource(); + ConsoleCancelEventHandler cancelHandler = null; + + cancelHandler = (sender, e) => + { + e.Cancel = true; + Console.Error.WriteLine("[MCP] Shutting down ABP MCP Server..."); + + try + { + cts.Cancel(); + } + catch (ObjectDisposedException) + { + // CTS already disposed + } + }; + + Console.CancelKeyPress += cancelHandler; + + try + { + await _mcpServerService.RunAsync(cts.Token); + } + catch (OperationCanceledException) + { + // Expected when Ctrl+C is pressed + } + catch (Exception ex) + { + await Console.Error.WriteLineAsync($"[MCP] Error running MCP server: {ex.Message}"); + throw; + } + finally + { + Console.CancelKeyPress -= cancelHandler; + cts.Dispose(); + } + } + + private Task PrintConfigurationAsync() + { + var abpCliPath = GetAbpCliExecutablePath(); + + var config = new McpClientConfiguration + { + McpServers = new Dictionary + { + ["abp"] = new McpServerConfig + { + Command = abpCliPath, + Args = new List { "mcp" }, + Env = new Dictionary() + } + } + }; + + var json = JsonSerializer.Serialize(config, new JsonSerializerOptions + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + + Console.WriteLine(json); + + return Task.CompletedTask; + } + + private string GetAbpCliExecutablePath() + { + // Try to find the abp CLI executable + try + { + using (var process = Process.GetCurrentProcess()) + { + var processPath = process.MainModule?.FileName; + + if (!string.IsNullOrEmpty(processPath)) + { + // If running as a published executable + if (Path.GetFileName(processPath).StartsWith("abp", StringComparison.OrdinalIgnoreCase)) + { + return processPath; + } + } + } + } + catch + { + // Ignore errors getting process path + } + + // Check if abp is in PATH + var pathEnv = Environment.GetEnvironmentVariable("PATH"); + if (!string.IsNullOrEmpty(pathEnv)) + { + 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; + } + } + } + + // Default to "abp" and let the system resolve it + return "abp"; } public string GetUsageInfo() diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Models/McpClientConfiguration.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Models/McpClientConfiguration.cs new file mode 100644 index 0000000000..60b21cbb33 --- /dev/null +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Models/McpClientConfiguration.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Volo.Abp.Cli.Commands.Models; + +public class McpClientConfiguration +{ + [JsonPropertyName("mcpServers")] + public Dictionary McpServers { get; set; } = new(); +} + +public class McpServerConfig +{ + [JsonPropertyName("command")] + public string Command { get; set; } + + [JsonPropertyName("args")] + public List Args { get; set; } = new(); + + [JsonPropertyName("env")] + public Dictionary Env { 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 new file mode 100644 index 0000000000..9a4c9bd16c --- /dev/null +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpHttpClientService.cs @@ -0,0 +1,109 @@ +using System; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Volo.Abp.Cli.Http; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.Cli.Commands.Services; + +public class McpHttpClientService : ITransientDependency +{ + private readonly CliHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + + private const string DefaultMcpServerUrl = "https://mcp.abp.io"; + private const string LocalMcpServerUrl = "http://localhost:5100"; + + public McpHttpClientService( + CliHttpClientFactory httpClientFactory, + ILogger logger) + { + _httpClientFactory = httpClientFactory; + _logger = logger; + } + + public async Task CallToolAsync(string toolName, JsonElement arguments, bool useLocalServer = false) + { + var baseUrl = LocalMcpServerUrl;//useLocalServer ? LocalMcpServerUrl : DefaultMcpServerUrl; + var url = $"{baseUrl}/tools/call"; + + try + { + using var httpClient = _httpClientFactory.CreateClient(needsAuthentication: true); + + var requestBody = new + { + name = toolName, + arguments = arguments + }; + + var jsonContent = JsonSerializer.Serialize(requestBody, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + + var content = new StringContent(jsonContent, Encoding.UTF8, "application/json"); + + var response = await httpClient.PostAsync(url, content); + + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(); + // Log to stderr to avoid corrupting stdout + await Console.Error.WriteLineAsync($"[MCP] API call failed: {response.StatusCode} - {errorContent}"); + + return JsonSerializer.Serialize(new + { + content = new[] + { + new + { + type = "text", + text = $"Error: {response.StatusCode} - {errorContent}" + } + } + }); + } + + return await response.Content.ReadAsStringAsync(); + } + catch (Exception ex) + { + // Log to stderr to avoid corrupting stdout + await Console.Error.WriteLineAsync($"[MCP] Error calling MCP tool '{toolName}': {ex.Message}"); + + return JsonSerializer.Serialize(new + { + content = new[] + { + new + { + type = "text", + text = $"Error: {ex.Message}" + } + } + }); + } + } + + public async Task CheckServerHealthAsync(bool useLocalServer = false) + { + var baseUrl = useLocalServer ? LocalMcpServerUrl : DefaultMcpServerUrl; + + try + { + using var httpClient = _httpClientFactory.CreateClient(needsAuthentication: false); + var response = await httpClient.GetAsync(baseUrl); + return response.IsSuccessStatusCode; + } + catch (Exception ex) + { + // Silently fail health check - it's optional + return false; + } + } +} + 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 new file mode 100644 index 0000000000..88669bec02 --- /dev/null +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpServerService.cs @@ -0,0 +1,365 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.Cli.Commands.Services; + +public class McpServerService : ITransientDependency +{ + private readonly McpHttpClientService _mcpHttpClient; + private readonly ILogger _logger; + + public McpServerService( + McpHttpClientService mcpHttpClient, + ILogger logger) + { + _mcpHttpClient = mcpHttpClient; + _logger = logger; + } + + public async Task RunAsync(CancellationToken cancellationToken = default) + { + try + { + // Write to stderr to avoid corrupting stdout JSON-RPC stream + await Console.Error.WriteLineAsync("[MCP] ABP MCP Server started successfully"); + + await ProcessStdioAsync(cancellationToken); + } + catch (OperationCanceledException) + { + await Console.Error.WriteLineAsync("[MCP] ABP MCP Server stopped"); + } + catch (Exception ex) + { + await Console.Error.WriteLineAsync($"[MCP] Error running ABP MCP Server: {ex.Message}"); + throw; + } + } + + private async Task ProcessStdioAsync(CancellationToken cancellationToken) + { + using var reader = new StreamReader(Console.OpenStandardInput()); + using var writer = new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true }; + + while (!cancellationToken.IsCancellationRequested) + { + string line; + try + { + var readTask = reader.ReadLineAsync(); + var completedTask = await Task.WhenAny(readTask, Task.Delay(Timeout.Infinite, cancellationToken)); + + if (completedTask != readTask) + { + // Cancellation requested + break; + } + + line = await readTask; + } + catch (OperationCanceledException) + { + break; + } + + if (line == null) + { + // EOF reached + break; + } + + // Skip empty lines or lines that are not JSON + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + // Check if line looks like JSON (starts with '{') + if (!line.TrimStart().StartsWith("{")) + { + // Not JSON, probably build output or other noise - log to stderr and skip + await Console.Error.WriteLineAsync($"[MCP] Skipping non-JSON line: {line.Substring(0, Math.Min(50, line.Length))}..."); + continue; + } + + try + { + var request = JsonSerializer.Deserialize(line, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + var response = await HandleRequestAsync(request, cancellationToken); + + var responseJson = JsonSerializer.Serialize(response, new JsonSerializerOptions + { + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + + await writer.WriteLineAsync(responseJson); + } + catch (JsonException jsonEx) + { + // JSON parse error - log to stderr but don't send error response + // (the line might be build output or other noise) + await Console.Error.WriteLineAsync($"[MCP] JSON parse error: {jsonEx.Message} | Line: {line.Substring(0, Math.Min(100, line.Length))}"); + } + catch (Exception ex) + { + // Other errors during request handling - send error response to client + await Console.Error.WriteLineAsync($"[MCP] Error processing request: {ex.Message}"); + + var errorResponse = new McpResponse + { + Jsonrpc = "2.0", + Id = null, + Error = new McpError + { + Code = -32603, + Message = ex.Message + } + }; + + var errorJson = JsonSerializer.Serialize(errorResponse, new JsonSerializerOptions + { + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + + await writer.WriteLineAsync(errorJson); + } + } + + await Console.Error.WriteLineAsync("[MCP] Stdio processing loop ended"); + } + + private async Task HandleRequestAsync(McpRequest request, CancellationToken cancellationToken) + { + if (request.Method == "initialize") + { + return new McpResponse + { + Jsonrpc = "2.0", + Id = request.Id, + Result = new + { + protocolVersion = "2024-11-05", + capabilities = new + { + tools = new { } + }, + serverInfo = new + { + name = "abp-mcp-server", + version = "1.0.0" + } + } + }; + } + + if (request.Method == "tools/list") + { + return new McpResponse + { + Jsonrpc = "2.0", + Id = request.Id, + Result = new + { + tools = GetToolDefinitions() + } + }; + } + + if (request.Method == "tools/call") + { + var toolName = request.Params.GetProperty("name").GetString(); + var arguments = request.Params.GetProperty("arguments"); + + var result = await _mcpHttpClient.CallToolAsync(toolName, arguments); + var toolResponse = JsonSerializer.Deserialize(result); + + return new McpResponse + { + Jsonrpc = "2.0", + Id = request.Id, + Result = toolResponse + }; + } + + return new McpResponse + { + Jsonrpc = "2.0", + Id = request.Id, + Error = new McpError + { + Code = -32601, + Message = $"Method not found: {request.Method}" + } + }; + } + + private object[] GetToolDefinitions() + { + return new object[] + { + new + { + name = "get_relevant_abp_documentation", + description = "Search ABP framework technical documentation including official guides, API references, and framework documentation.", + inputSchema = new + { + type = "object", + properties = new + { + query = new + { + type = "string", + description = "The search query to find relevant documentation" + } + }, + required = new[] { "query" } + } + }, + new + { + name = "get_relevant_abp_articles", + description = "Search ABP blog posts, tutorials, and community-contributed content.", + inputSchema = new + { + type = "object", + properties = new + { + query = new + { + type = "string", + description = "The search query to find relevant articles" + } + }, + required = new[] { "query" } + } + }, + new + { + name = "get_relevant_abp_support_questions", + description = "Search support ticket history containing real-world problems and their solutions.", + inputSchema = new + { + type = "object", + properties = new + { + query = new + { + type = "string", + description = "The search query to find relevant support questions" + } + }, + required = new[] { "query" } + } + }, + new + { + name = "search_code", + description = "Search for code across ABP repositories using regex patterns.", + inputSchema = 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" } + } + }, + new + { + name = "list_repos", + description = "List all available ABP repositories in SourceBot.", + inputSchema = new + { + type = "object", + properties = new { } + } + }, + new + { + name = "get_file_source", + description = "Retrieve the complete source code of a specific file from an ABP repository.", + inputSchema = new + { + 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" } + } + } + }; + } +} + +internal class McpRequest +{ + [System.Text.Json.Serialization.JsonPropertyName("jsonrpc")] + public string Jsonrpc { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("id")] + public object Id { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("method")] + public string Method { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("params")] + public JsonElement Params { get; set; } +} + +internal class McpResponse +{ + [System.Text.Json.Serialization.JsonPropertyName("jsonrpc")] + public string Jsonrpc { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("id")] + public object Id { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("result")] + public object Result { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("error")] + public McpError Error { get; set; } +} + +internal class McpError +{ + [System.Text.Json.Serialization.JsonPropertyName("code")] + public int Code { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("message")] + public string Message { get; set; } +} + From 6007b2f587b430b7f0b3b960df42d29c610945da Mon Sep 17 00:00:00 2001 From: Mansur Besleney Date: Fri, 2 Jan 2026 13:39:54 +0300 Subject: [PATCH 04/26] refacto the mcp server service code to use the mcp package --- .../Volo/Abp/Cli/CliService.cs | 27 +- .../Volo/Abp/Cli/Commands/CommandSelector.cs | 6 + .../Volo/Abp/Cli/Commands/HelpCommand.cs | 8 + .../Commands/Services/McpHttpClientService.cs | 2 +- .../Cli/Commands/Services/McpServerService.cs | 488 +++++++----------- 5 files changed, 234 insertions(+), 297 deletions(-) diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/CliService.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/CliService.cs index fdc82c73fc..42b8c93219 100644 --- a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/CliService.cs +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/CliService.cs @@ -58,14 +58,17 @@ public class CliService : ITransientDependency var commandLineArgs = CommandLineArgumentParser.Parse(args); var currentCliVersion = await CliVersionService.GetCurrentCliVersionAsync(); + var isMcpCommand = commandLineArgs.IsCommand("mcp"); + // Don't print banner for MCP command to avoid corrupting stdout JSON-RPC stream - if (!commandLineArgs.IsCommand("mcp")) + if (!isMcpCommand) { Logger.LogInformation($"ABP CLI {currentCliVersion}"); } #if !DEBUG - if (!commandLineArgs.Options.ContainsKey("skip-cli-version-check")) + // Skip version check for MCP command to avoid corrupting stdout JSON-RPC stream + if (!isMcpCommand && !commandLineArgs.Options.ContainsKey("skip-cli-version-check")) { await CheckCliVersionAsync(currentCliVersion); } @@ -89,13 +92,29 @@ public class CliService : ITransientDependency } catch (CliUsageException usageException) { - Logger.LogWarning(usageException.Message); + // For MCP command, write errors to stderr to avoid corrupting stdout JSON-RPC stream + if (commandLineArgs.IsCommand("mcp")) + { + await Console.Error.WriteLineAsync($"[MCP] Error: {usageException.Message}"); + } + else + { + Logger.LogWarning(usageException.Message); + } Environment.ExitCode = 1; } catch (Exception ex) { await _telemetryService.AddErrorActivityAsync(ex.Message); - Logger.LogException(ex); + // For MCP command, write errors to stderr to avoid corrupting stdout JSON-RPC stream + if (commandLineArgs.IsCommand("mcp")) + { + await Console.Error.WriteLineAsync($"[MCP] Fatal error: {ex.Message}"); + } + else + { + Logger.LogException(ex); + } throw; } finally diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/CommandSelector.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/CommandSelector.cs index 9bfdcfac5c..cf73aa21a7 100644 --- a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/CommandSelector.cs +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/CommandSelector.cs @@ -17,6 +17,12 @@ 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")) + { + return Options.Commands.GetOrDefault("mcp") ?? typeof(HelpCommand); + } + if (commandLineArgs.Command.IsNullOrWhiteSpace()) { return typeof(HelpCommand); diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/HelpCommand.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/HelpCommand.cs index cc13e9d187..43a36a8b74 100644 --- a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/HelpCommand.cs +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/HelpCommand.cs @@ -32,6 +32,14 @@ public class HelpCommand : IConsoleCommand, ITransientDependency public Task ExecuteAsync(CommandLineArgs commandLineArgs) { + // 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")) + { + // Silently return - MCP server should handle its own errors + return Task.CompletedTask; + } + if (string.IsNullOrWhiteSpace(commandLineArgs.Target)) { Logger.LogInformation(GetUsageInfo()); 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 9a4c9bd16c..baa5dbbd97 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 @@ -99,7 +99,7 @@ public class McpHttpClientService : ITransientDependency var response = await httpClient.GetAsync(baseUrl); return response.IsSuccessStatusCode; } - catch (Exception ex) + catch (Exception) { // Silently fail health check - it's optional return false; 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 88669bec02..7ad6a45ad1 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,11 +1,11 @@ using System; using System.Collections.Generic; -using System.IO; -using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; using Volo.Abp.DependencyInjection; namespace Volo.Abp.Cli.Commands.Services; @@ -14,352 +14,256 @@ public class McpServerService : ITransientDependency { private readonly McpHttpClientService _mcpHttpClient; private readonly ILogger _logger; + private readonly ILoggerFactory _loggerFactory; public McpServerService( McpHttpClientService mcpHttpClient, - ILogger logger) + ILogger logger, + ILoggerFactory loggerFactory) { _mcpHttpClient = mcpHttpClient; _logger = logger; + _loggerFactory = loggerFactory; } public async Task RunAsync(CancellationToken cancellationToken = default) { - try - { - // Write to stderr to avoid corrupting stdout JSON-RPC stream - await Console.Error.WriteLineAsync("[MCP] ABP MCP Server started successfully"); + // Log to stderr to avoid corrupting stdout JSON-RPC stream + await Console.Error.WriteLineAsync("[MCP] Starting ABP MCP Server (stdio)"); - await ProcessStdioAsync(cancellationToken); - } - catch (OperationCanceledException) - { - await Console.Error.WriteLineAsync("[MCP] ABP MCP Server stopped"); - } - catch (Exception ex) - { - await Console.Error.WriteLineAsync($"[MCP] Error running ABP MCP Server: {ex.Message}"); - throw; - } + var options = new McpServerOptions(); + + RegisterAllTools(options); + + var server = McpServer.Create( + new StdioServerTransport("abp-mcp-server", _loggerFactory), + options + ); + + await server.RunAsync(cancellationToken); + + await Console.Error.WriteLineAsync("[MCP] ABP MCP Server stopped"); } - private async Task ProcessStdioAsync(CancellationToken cancellationToken) + private void RegisterAllTools(McpServerOptions options) { - using var reader = new StreamReader(Console.OpenStandardInput()); - using var writer = new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true }; - - while (!cancellationToken.IsCancellationRequested) - { - string line; - try + RegisterTool( + options, + "get_relevant_abp_documentation", + "Search ABP framework technical documentation including official guides, API references, and framework documentation.", + new { - var readTask = reader.ReadLineAsync(); - var completedTask = await Task.WhenAny(readTask, Task.Delay(Timeout.Infinite, cancellationToken)); - - if (completedTask != readTask) + type = "object", + properties = new { - // Cancellation requested - break; - } - - line = await readTask; - } - catch (OperationCanceledException) - { - break; - } - - if (line == null) - { - // EOF reached - break; - } - - // Skip empty lines or lines that are not JSON - if (string.IsNullOrWhiteSpace(line)) - { - continue; - } - - // Check if line looks like JSON (starts with '{') - if (!line.TrimStart().StartsWith("{")) - { - // Not JSON, probably build output or other noise - log to stderr and skip - await Console.Error.WriteLineAsync($"[MCP] Skipping non-JSON line: {line.Substring(0, Math.Min(50, line.Length))}..."); - continue; + query = new + { + type = "string", + description = "The search query to find relevant documentation" + } + }, + required = new[] { "query" } } + ); - try + RegisterTool( + options, + "get_relevant_abp_articles", + "Search ABP blog posts, tutorials, and community-contributed content.", + new { - var request = JsonSerializer.Deserialize(line, new JsonSerializerOptions + type = "object", + properties = new { - PropertyNameCaseInsensitive = true - }); - - var response = await HandleRequestAsync(request, cancellationToken); - - var responseJson = JsonSerializer.Serialize(response, new JsonSerializerOptions - { - DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - }); - - await writer.WriteLineAsync(responseJson); - } - catch (JsonException jsonEx) - { - // JSON parse error - log to stderr but don't send error response - // (the line might be build output or other noise) - await Console.Error.WriteLineAsync($"[MCP] JSON parse error: {jsonEx.Message} | Line: {line.Substring(0, Math.Min(100, line.Length))}"); + query = new + { + type = "string", + description = "The search query to find relevant articles" + } + }, + required = new[] { "query" } } - catch (Exception ex) + ); + + RegisterTool( + options, + "get_relevant_abp_support_questions", + "Search support ticket history containing real-world problems and their solutions.", + new { - // Other errors during request handling - send error response to client - await Console.Error.WriteLineAsync($"[MCP] Error processing request: {ex.Message}"); - - var errorResponse = new McpResponse + type = "object", + properties = new { - Jsonrpc = "2.0", - Id = null, - Error = new McpError + query = new { - Code = -32603, - Message = ex.Message + type = "string", + description = "The search query to find relevant support questions" } - }; - - var errorJson = JsonSerializer.Serialize(errorResponse, new JsonSerializerOptions - { - DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - }); - - await writer.WriteLineAsync(errorJson); + }, + required = new[] { "query" } } - } - - await Console.Error.WriteLineAsync("[MCP] Stdio processing loop ended"); - } + ); - private async Task HandleRequestAsync(McpRequest request, CancellationToken cancellationToken) - { - if (request.Method == "initialize") - { - return new McpResponse + RegisterTool( + options, + "search_code", + "Search for code across ABP repositories using regex patterns.", + new { - Jsonrpc = "2.0", - Id = request.Id, - Result = new + type = "object", + properties = new { - protocolVersion = "2024-11-05", - capabilities = new + query = new { - tools = new { } + type = "string", + description = "The regex pattern or search query to find code" }, - serverInfo = new + repo_filter = new { - name = "abp-mcp-server", - version = "1.0.0" + type = "string", + description = "Optional repository filter to limit search scope" } - } - }; - } - - if (request.Method == "tools/list") - { - return new McpResponse - { - Jsonrpc = "2.0", - Id = request.Id, - Result = new - { - tools = GetToolDefinitions() - } - }; - } - - if (request.Method == "tools/call") - { - var toolName = request.Params.GetProperty("name").GetString(); - var arguments = request.Params.GetProperty("arguments"); - - var result = await _mcpHttpClient.CallToolAsync(toolName, arguments); - var toolResponse = JsonSerializer.Deserialize(result); - - return new McpResponse - { - Jsonrpc = "2.0", - Id = request.Id, - Result = toolResponse - }; - } + }, + required = new[] { "query" } + } + ); - return new McpResponse - { - Jsonrpc = "2.0", - Id = request.Id, - Error = new McpError + RegisterTool( + options, + "list_repos", + "List all available ABP repositories in SourceBot.", + new { - Code = -32601, - Message = $"Method not found: {request.Method}" + type = "object", + properties = new { } } - }; - } + ); - private object[] GetToolDefinitions() - { - return new object[] - { + RegisterTool( + options, + "get_file_source", + "Retrieve the complete source code of a specific file from an ABP repository.", new { - name = "get_relevant_abp_documentation", - description = "Search ABP framework technical documentation including official guides, API references, and framework documentation.", - inputSchema = new + type = "object", + properties = new { - type = "object", - properties = new + repoId = new { - query = new - { - type = "string", - description = "The search query to find relevant documentation" - } + type = "string", + description = "The repository identifier containing the file" }, - required = new[] { "query" } - } - }, - new - { - name = "get_relevant_abp_articles", - description = "Search ABP blog posts, tutorials, and community-contributed content.", - inputSchema = new - { - type = "object", - properties = new + fileName = new { - query = new - { - type = "string", - description = "The search query to find relevant articles" - } - }, - required = new[] { "query" } - } - }, - new + type = "string", + description = "The file path or name to retrieve" + } + }, + required = new[] { "repoId", "fileName" } + } + ); + } + + private void RegisterTool( + McpServerOptions options, + string name, + string description, + object inputSchema) + { + if (options.ToolCollection == null) + { + options.ToolCollection = new McpServerPrimitiveCollection(); + } + + var tool = new AbpMcpServerTool( + name, + description, + JsonSerializer.SerializeToElement(inputSchema), + async (context, cancellationToken) => { - name = "get_relevant_abp_support_questions", - description = "Search support ticket history containing real-world problems and their solutions.", - inputSchema = new + // Log to stderr to avoid corrupting stdout JSON-RPC stream + await Console.Error.WriteLineAsync($"[MCP] Tool '{name}' called with arguments: {context.Params.Arguments}"); + + try { - type = "object", - properties = new + var argumentsDict = context.Params.Arguments; + var argumentsJson = JsonSerializer.SerializeToElement(argumentsDict); + var resultJson = await _mcpHttpClient.CallToolAsync( + name, + argumentsJson + ); + + await Console.Error.WriteLineAsync($"[MCP] Tool '{name}' executed successfully"); + + // Try to deserialize the response as CallToolResult + // The HTTP client should return JSON in the format expected by MCP + try { - query = new + var callToolResult = JsonSerializer.Deserialize(resultJson); + if (callToolResult != null) { - type = "string", - description = "The search query to find relevant support questions" + return callToolResult; } - }, - required = new[] { "query" } - } - }, - new - { - name = "search_code", - description = "Search for code across ABP repositories using regex patterns.", - inputSchema = new - { - type = "object", - properties = new + } + catch (Exception deserializeEx) { - 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" } - } - }, - new - { - name = "list_repos", - description = "List all available ABP repositories in SourceBot.", - inputSchema = new - { - type = "object", - properties = new { } + await Console.Error.WriteLineAsync($"[MCP] Failed to deserialize response as CallToolResult: {deserializeEx.Message}"); + await Console.Error.WriteLineAsync($"[MCP] Response was: {resultJson.Substring(0, Math.Min(500, resultJson.Length))}"); + } + + // Fallback: return empty result if deserialization fails + return new CallToolResult + { + Content = new List() + }; } - }, - new - { - name = "get_file_source", - description = "Retrieve the complete source code of a specific file from an ABP repository.", - inputSchema = new + catch (Exception ex) { - type = "object", - properties = new + await Console.Error.WriteLineAsync($"[MCP] Tool '{name}' execution failed: {ex.Message}"); + return new CallToolResult { - 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" } + Content = new List(), + IsError = true + }; } } - }; + ); + + options.ToolCollection.Add(tool); } -} -internal class McpRequest -{ - [System.Text.Json.Serialization.JsonPropertyName("jsonrpc")] - public string Jsonrpc { get; set; } - - [System.Text.Json.Serialization.JsonPropertyName("id")] - public object Id { get; set; } - - [System.Text.Json.Serialization.JsonPropertyName("method")] - public string Method { get; set; } - - [System.Text.Json.Serialization.JsonPropertyName("params")] - public JsonElement Params { get; set; } -} + private class AbpMcpServerTool : McpServerTool + { + private readonly string _name; + private readonly string _description; + private readonly JsonElement _inputSchema; + private readonly Func, CancellationToken, ValueTask> _handler; -internal class McpResponse -{ - [System.Text.Json.Serialization.JsonPropertyName("jsonrpc")] - public string Jsonrpc { get; set; } - - [System.Text.Json.Serialization.JsonPropertyName("id")] - public object Id { get; set; } - - [System.Text.Json.Serialization.JsonPropertyName("result")] - public object Result { get; set; } - - [System.Text.Json.Serialization.JsonPropertyName("error")] - public McpError Error { get; set; } -} + public AbpMcpServerTool( + string name, + string description, + JsonElement inputSchema, + Func, CancellationToken, ValueTask> handler) + { + _name = name; + _description = description; + _inputSchema = inputSchema; + _handler = handler; + } -internal class McpError -{ - [System.Text.Json.Serialization.JsonPropertyName("code")] - public int Code { get; set; } - - [System.Text.Json.Serialization.JsonPropertyName("message")] - public string Message { get; set; } -} + public override Tool ProtocolTool => new Tool + { + Name = _name, + Description = _description, + InputSchema = _inputSchema + }; + + public override IReadOnlyList Metadata => Array.Empty(); + public override ValueTask InvokeAsync(RequestContext context, CancellationToken cancellationToken) + { + return _handler(context, cancellationToken); + } + } + +} From ee754b04bcfe181a62a31ec61f809dd99916fdd1 Mon Sep 17 00:00:00 2001 From: Mansur Besleney Date: Fri, 2 Jan 2026 13:55:54 +0300 Subject: [PATCH 05/26] Update McpServerService.cs --- .../Abp/Cli/Commands/Services/McpServerService.cs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) 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 7ad6a45ad1..0b03781466 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 @@ -4,6 +4,7 @@ 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; using Volo.Abp.DependencyInjection; @@ -13,17 +14,11 @@ namespace Volo.Abp.Cli.Commands.Services; public class McpServerService : ITransientDependency { private readonly McpHttpClientService _mcpHttpClient; - private readonly ILogger _logger; - private readonly ILoggerFactory _loggerFactory; public McpServerService( - McpHttpClientService mcpHttpClient, - ILogger logger, - ILoggerFactory loggerFactory) + McpHttpClientService mcpHttpClient) { _mcpHttpClient = mcpHttpClient; - _logger = logger; - _loggerFactory = loggerFactory; } public async Task RunAsync(CancellationToken cancellationToken = default) @@ -35,8 +30,10 @@ public class McpServerService : ITransientDependency RegisterAllTools(options); + // Use NullLoggerFactory to prevent ModelContextProtocol library from logging to stdout + // All our logging goes to stderr via Console.Error var server = McpServer.Create( - new StdioServerTransport("abp-mcp-server", _loggerFactory), + new StdioServerTransport("abp-mcp-server", NullLoggerFactory.Instance), options ); From 7821fc173bf5ddd392fa1d98e5eb901bd3df42de Mon Sep 17 00:00:00 2001 From: Mansur Besleney Date: Wed, 7 Jan 2026 12:05:31 +0300 Subject: [PATCH 06/26] 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; + } +} + From 6f70ced612ce2a0059138646aa7309f609e04d81 Mon Sep 17 00:00:00 2001 From: Mansur Besleney Date: Wed, 7 Jan 2026 14:09:38 +0300 Subject: [PATCH 07/26] 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. --- .../Volo/Abp/Cli/CliConsts.cs | 4 + .../Commands/Services/McpHttpClientService.cs | 64 +++++-- .../Services/McpToolDefinitionValidator.cs | 181 ++++++++++++++++++ .../Commands/Services/McpToolsCacheService.cs | 45 ++++- 4 files changed, 272 insertions(+), 22 deletions(-) create mode 100644 framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpToolDefinitionValidator.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 27eb1274bf..8e9125a52c 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 @@ -20,9 +20,13 @@ public static class CliConsts 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 const string LatestCliVersionCheckDate = "LatestCliVersionCheckDate"; public const string McpToolsLastFetchDate = "McpToolsLastFetchDate"; + public const string McpServerUrl = "McpServerUrl"; } } 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 985c96e6f0..e8ba542e5c 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 @@ -7,6 +7,7 @@ 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; namespace Volo.Abp.Cli.Commands.Services; @@ -15,21 +16,51 @@ public class McpHttpClientService : ITransientDependency { private readonly CliHttpClientFactory _httpClientFactory; private readonly ILogger _logger; - - private const string DefaultMcpServerUrl = "https://mcp.abp.io"; - private const string LocalMcpServerUrl = "http://localhost:5100"; + private readonly MemoryService _memoryService; + private string _cachedServerUrl; public McpHttpClientService( CliHttpClientFactory httpClientFactory, - ILogger logger) + ILogger logger, + MemoryService memoryService) { _httpClientFactory = httpClientFactory; _logger = logger; + _memoryService = memoryService; } - public async Task CallToolAsync(string toolName, JsonElement arguments, bool useLocalServer = false) + private async Task 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 CallToolAsync(string toolName, JsonElement arguments) + { + var baseUrl = "http://localhost:5100";//await GetMcpServerUrlAsync(); var url = $"{baseUrl}/tools/call"; try @@ -53,9 +84,8 @@ public class McpHttpClientService : ITransientDependency if (!response.IsSuccessStatusCode) { - var errorContent = await response.Content.ReadAsStringAsync(); - // Log to stderr to avoid corrupting stdout - await Console.Error.WriteLineAsync($"[MCP] API call failed: {response.StatusCode} - {errorContent}"); + // Log to stderr to avoid corrupting stdout - sanitize error message + await Console.Error.WriteLineAsync($"[MCP] API call failed with status: {response.StatusCode}"); return JsonSerializer.Serialize(new { @@ -64,7 +94,7 @@ public class McpHttpClientService : ITransientDependency new { 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 CheckServerHealthAsync(bool useLocalServer = false) + public async Task CheckServerHealthAsync() { - var baseUrl = useLocalServer ? LocalMcpServerUrl : DefaultMcpServerUrl; + var baseUrl = "http://localhost:5100";//await GetMcpServerUrlAsync(); try { @@ -108,9 +138,9 @@ public class McpHttpClientService : ITransientDependency } } - public async Task> GetToolDefinitionsAsync(bool useLocalServer = false) + public async Task> GetToolDefinitionsAsync() { - var baseUrl = LocalMcpServerUrl; //useLocalServer ? LocalMcpServerUrl : DefaultMcpServerUrl; + var baseUrl = "http://localhost:5100";//await GetMcpServerUrlAsync(); var url = $"{baseUrl}/tools"; try @@ -120,9 +150,9 @@ public class McpHttpClientService : ITransientDependency 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}"); + // Sanitize error message - don't expose server details + await Console.Error.WriteLineAsync($"[MCP] Failed to fetch tool definitions with status: {response.StatusCode}"); + throw new Exception($"Failed to fetch tool definitions with status: {response.StatusCode}"); } var responseContent = await response.Content.ReadAsStringAsync(); diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpToolDefinitionValidator.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpToolDefinitionValidator.cs new file mode 100644 index 0000000000..49d8c6afc6 --- /dev/null +++ b/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 = ""; + + private static readonly Regex ToolNameRegex = new Regex("^[a-zA-Z0-9_]+$", RegexOptions.Compiled); + private static readonly HashSet ValidTypeValues = new HashSet + { "string", "number", "boolean", "object", "array" }; + + private readonly ILogger _logger; + + public McpToolDefinitionValidator(ILogger logger) + { + _logger = logger; + } + + public List ValidateAndFilter(List tools) + { + if (tools == null || tools.Count == 0) + { + return new List(); + } + + var validTools = new List(); + + 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; + } +} + 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 index 3b2b664c7c..06d4699d81 100644 --- 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 @@ -16,15 +16,18 @@ public class McpToolsCacheService : ITransientDependency { private readonly McpHttpClientService _mcpHttpClient; private readonly MemoryService _memoryService; + private readonly McpToolDefinitionValidator _validator; private readonly ILogger _logger; public McpToolsCacheService( McpHttpClientService mcpHttpClient, MemoryService memoryService, + McpToolDefinitionValidator validator, ILogger logger) { _mcpHttpClient = mcpHttpClient; _memoryService = memoryService; + _validator = validator; _logger = logger; } @@ -46,16 +49,27 @@ public class McpToolsCacheService : ITransientDependency await Console.Error.WriteLineAsync("[MCP] Fetching tool definitions from server..."); var tools = await _mcpHttpClient.GetToolDefinitionsAsync(); - // Save to cache - await SaveToCacheAsync(tools); + // Validate and filter tool definitions + 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(); + } + + // Save validated tools to cache + await SaveToCacheAsync(validTools); 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; + await Console.Error.WriteLineAsync($"[MCP] Successfully fetched and cached {validTools.Count} tool definitions"); + return validTools; } 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..."); // Fall back to cache even if expired @@ -148,6 +162,9 @@ public class McpToolsCacheService : ITransientDependency }); File.WriteAllText(CliPaths.McpToolsCache, json); + + // Set restrictive file permissions (user read/write only) + SetRestrictiveFilePermissions(CliPaths.McpToolsCache); } catch (Exception ex) { @@ -156,5 +173,23 @@ public class McpToolsCacheService : ITransientDependency 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}"); + } + } } From 145a3cd524d58a8e39e368c35fbd2b2883f4cbf9 Mon Sep 17 00:00:00 2001 From: Mansur Besleney Date: Thu, 8 Jan 2026 11:04:25 +0300 Subject: [PATCH 08/26] Improve MCP error handling and licensing checks Enhanced error handling and user messaging in MCP HTTP client and server services, providing sanitized and user-friendly error responses for network, timeout, and unexpected errors. Updated MCP command to enforce license validation before tool execution. Improved cross-platform file permission handling in the tools cache service. --- .../Volo/Abp/Cli/CliConsts.cs | 2 +- .../Volo/Abp/Cli/Commands/McpCommand.cs | 21 ++- .../Commands/Services/McpHttpClientService.cs | 152 +++++++++++++----- .../Cli/Commands/Services/McpServerService.cs | 51 ++++-- .../Commands/Services/McpToolsCacheService.cs | 5 +- 5 files changed, 168 insertions(+), 63 deletions(-) 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 8e9125a52c..821a78cc14 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 @@ -1,4 +1,4 @@ -namespace Volo.Abp.Cli; +namespace Volo.Abp.Cli; public static class CliConsts { diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/McpCommand.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/McpCommand.cs index 4eeb3af0c1..7463a8f5a7 100644 --- a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/McpCommand.cs +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/McpCommand.cs @@ -13,6 +13,7 @@ using Volo.Abp.Cli.Args; using Volo.Abp.Cli.Auth; using Volo.Abp.Cli.Commands.Models; using Volo.Abp.Cli.Commands.Services; +using Volo.Abp.Cli.Licensing; using Volo.Abp.DependencyInjection; namespace Volo.Abp.Cli.Commands; @@ -22,19 +23,19 @@ public class McpCommand : IConsoleCommand, ITransientDependency public const string Name = "mcp"; private readonly AuthService _authService; - private readonly AbpNuGetIndexUrlService _nuGetIndexUrlService; + private readonly IApiKeyService _apiKeyService; private readonly McpServerService _mcpServerService; private readonly McpHttpClientService _mcpHttpClient; public ILogger Logger { get; set; } public McpCommand( - AbpNuGetIndexUrlService nuGetIndexUrlService, + IApiKeyService apiKeyService, AuthService authService, McpServerService mcpServerService, McpHttpClientService mcpHttpClient) { - _nuGetIndexUrlService = nuGetIndexUrlService; + _apiKeyService = apiKeyService; _authService = authService; _mcpServerService = mcpServerService; _mcpHttpClient = mcpHttpClient; @@ -50,11 +51,17 @@ public class McpCommand : IConsoleCommand, ITransientDependency throw new CliUsageException("Please log in with your account!"); } - var nugetIndexUrl = await _nuGetIndexUrlService.GetAsync(); - - if (nugetIndexUrl == null) + var licenseResult = await _apiKeyService.GetApiKeyOrNullAsync(); + + if (licenseResult == null || !licenseResult.HasActiveLicense) + { + var errorMessage = licenseResult?.ErrorMessage ?? "No active license found."; + throw new CliUsageException(errorMessage); + } + + if (licenseResult.LicenseEndTime.HasValue && licenseResult.LicenseEndTime.Value < DateTime.UtcNow) { - throw new CliUsageException("Could not find Nuget Index Url!"); + throw new CliUsageException("Your license has expired. Please renew your license to use the MCP server."); } var option = commandLineArgs.Target; 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 e8ba542e5c..97a4ddab36 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,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Net; using System.Net.Http; using System.Text; using System.Text.Json; @@ -14,6 +15,15 @@ namespace Volo.Abp.Cli.Commands.Services; public class McpHttpClientService : ITransientDependency { + private static readonly JsonSerializerOptions JsonSerializerOptionsWeb = new(JsonSerializerDefaults.Web); + + private static class ErrorMessages + { + public const string NetworkConnectivity = "The tool execution failed due to a network connectivity issue. Please check your internet connection and try again."; + public const string Timeout = "The tool execution timed out. The operation took too long to complete. Please try again."; + public const string Unexpected = "The tool execution failed due to an unexpected error. Please try again later."; + } + private readonly CliHttpClientFactory _httpClientFactory; private readonly ILogger _logger; private readonly MemoryService _memoryService; @@ -67,16 +77,9 @@ public class McpHttpClientService : ITransientDependency { using var httpClient = _httpClientFactory.CreateClient(needsAuthentication: true); - var requestBody = new - { - name = toolName, - arguments = arguments - }; - - var jsonContent = JsonSerializer.Serialize(requestBody, new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - }); + var jsonContent = JsonSerializer.Serialize( + new { name = toolName, arguments }, + JsonSerializerOptionsWeb); var content = new StringContent(jsonContent, Encoding.UTF8, "application/json"); @@ -84,41 +87,76 @@ public class McpHttpClientService : ITransientDependency if (!response.IsSuccessStatusCode) { - // Log to stderr to avoid corrupting stdout - sanitize error message + // Log detailed error to stderr for debugging await Console.Error.WriteLineAsync($"[MCP] API call failed with status: {response.StatusCode}"); - return JsonSerializer.Serialize(new - { - content = new[] - { - new - { - type = "text", - text = $"Error: API call failed with status {response.StatusCode}" - } - } - }); + // Return sanitized error message to client + var errorMessage = GetSanitizedHttpErrorMessage(response.StatusCode); + return CreateErrorResponse(errorMessage); } return await response.Content.ReadAsStringAsync(); } + catch (HttpRequestException ex) + { + // Log detailed error to stderr for debugging + await Console.Error.WriteLineAsync($"[MCP] Network error calling tool '{toolName}': {ex.Message}"); + + // Return sanitized error to client + return CreateErrorResponse(ErrorMessages.NetworkConnectivity); + } + catch (TaskCanceledException ex) + { + // Log detailed error to stderr for debugging + await Console.Error.WriteLineAsync($"[MCP] Timeout calling tool '{toolName}': {ex.Message}"); + + // Return sanitized error to client + return CreateErrorResponse(ErrorMessages.Timeout); + } catch (Exception ex) { - // Log to stderr to avoid corrupting stdout - await Console.Error.WriteLineAsync($"[MCP] Error calling MCP tool '{toolName}': {ex.Message}"); + // Log detailed error to stderr for debugging + await Console.Error.WriteLineAsync($"[MCP] Unexpected error calling tool '{toolName}': {ex.Message}"); - return JsonSerializer.Serialize(new + // Return generic sanitized error to client + return CreateErrorResponse(ErrorMessages.Unexpected); + } + } + + private string CreateErrorResponse(string errorMessage) + { + return JsonSerializer.Serialize(new + { + content = new[] { - content = new[] + new { - new - { - type = "text", - text = $"Error: {ex.Message}" - } + type = "text", + text = errorMessage } - }); - } + }, + isError = true + }, JsonSerializerOptionsWeb); + } + + private Exception CreateToolDefinitionException(string userMessage) + { + return new Exception($"Failed to fetch tool definitions: {userMessage}"); + } + + private string GetSanitizedHttpErrorMessage(HttpStatusCode statusCode) + { + return statusCode switch + { + HttpStatusCode.Unauthorized => "Authentication failed. Please ensure you are logged in with a valid account.", + 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.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.ServiceUnavailable => "The service is temporarily unavailable. 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." + }; } public async Task CheckServerHealthAsync() @@ -150,26 +188,58 @@ public class McpHttpClientService : ITransientDependency if (!response.IsSuccessStatusCode) { - // Sanitize error message - don't expose server details + // Log detailed error to stderr for debugging await Console.Error.WriteLineAsync($"[MCP] Failed to fetch tool definitions with status: {response.StatusCode}"); - throw new Exception($"Failed to fetch tool definitions with status: {response.StatusCode}"); + + // Throw sanitized exception + var errorMessage = GetSanitizedHttpErrorMessage(response.StatusCode); + throw CreateToolDefinitionException(errorMessage); } var responseContent = await response.Content.ReadAsStringAsync(); // The API returns { tools: [...] } format - var result = JsonSerializer.Deserialize(responseContent, new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }); + var result = JsonSerializer.Deserialize(responseContent, JsonSerializerOptionsWeb); return result?.Tools ?? new List(); } - catch (Exception ex) + catch (HttpRequestException ex) + { + // Log detailed error to stderr for debugging + await Console.Error.WriteLineAsync($"[MCP] Network error fetching tool definitions: {ex.Message}"); + + // Throw sanitized exception + throw CreateToolDefinitionException("Network connectivity issue. Please check your internet connection and try again."); + } + catch (TaskCanceledException ex) { - await Console.Error.WriteLineAsync($"[MCP] Error fetching tool definitions: {ex.Message}"); + // Log detailed error to stderr for debugging + await Console.Error.WriteLineAsync($"[MCP] Timeout fetching tool definitions: {ex.Message}"); + + // Throw sanitized exception + throw CreateToolDefinitionException("Request timed out. Please try again."); + } + catch (JsonException ex) + { + // Log detailed error to stderr for debugging + await Console.Error.WriteLineAsync($"[MCP] JSON parsing error: {ex.Message}"); + + // Throw sanitized exception + throw CreateToolDefinitionException("Invalid response format received."); + } + catch (Exception ex) when (ex.Message.StartsWith("Failed to fetch tool definitions:")) + { + // Already sanitized, rethrow as-is throw; } + catch (Exception ex) + { + // Log detailed error to stderr for debugging + await Console.Error.WriteLineAsync($"[MCP] Unexpected error fetching tool definitions: {ex.Message}"); + + // Throw sanitized exception + throw CreateToolDefinitionException("An unexpected error occurred. Please try again later."); + } } private class McpToolsResponse 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 02a5651ecb..dff741d11a 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 @@ -15,6 +15,12 @@ namespace Volo.Abp.Cli.Commands.Services; public class McpServerService : ITransientDependency { + private static class ToolErrorMessages + { + public const string InvalidResponseFormat = "The tool execution completed but returned an invalid response format. Please try again."; + public const string UnexpectedError = "The tool execution failed due to an unexpected error. Please try again later."; + } + private readonly McpHttpClientService _mcpHttpClient; private readonly McpToolsCacheService _toolsCacheService; @@ -91,6 +97,21 @@ public class McpServerService : ITransientDependency ); } + private static CallToolResult CreateErrorResult(string errorMessage) + { + return new CallToolResult + { + Content = new List + { + new TextContentBlock + { + Text = errorMessage + } + }, + IsError = true + }; + } + private void RegisterTool( McpServerOptions options, string name, @@ -120,8 +141,6 @@ public class McpServerService : ITransientDependency argumentsJson ); - await Console.Error.WriteLineAsync($"[MCP] Tool '{name}' executed successfully"); - // Try to deserialize the response as CallToolResult // The HTTP client should return JSON in the format expected by MCP try @@ -129,6 +148,16 @@ public class McpServerService : ITransientDependency var callToolResult = JsonSerializer.Deserialize(resultJson); if (callToolResult != null) { + // Check if the HTTP client returned an error + if (callToolResult.IsError == true) + { + await Console.Error.WriteLineAsync($"[MCP] Tool '{name}' returned an error"); + } + else + { + await Console.Error.WriteLineAsync($"[MCP] Tool '{name}' executed successfully"); + } + return callToolResult; } } @@ -138,20 +167,16 @@ public class McpServerService : ITransientDependency await Console.Error.WriteLineAsync($"[MCP] Response was: {resultJson.Substring(0, Math.Min(500, resultJson.Length))}"); } - // Fallback: return empty result if deserialization fails - return new CallToolResult - { - Content = new List() - }; + // Fallback: return error result if deserialization fails + return CreateErrorResult(ToolErrorMessages.InvalidResponseFormat); } catch (Exception ex) { - await Console.Error.WriteLineAsync($"[MCP] Tool '{name}' execution failed: {ex.Message}"); - return new CallToolResult - { - Content = new List(), - IsError = true - }; + // Log detailed error for debugging + await Console.Error.WriteLineAsync($"[MCP] Tool '{name}' execution failed with exception: {ex.Message}"); + + // Return sanitized error to client + return CreateErrorResult(ToolErrorMessages.UnexpectedError); } } ); 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 index 06d4699d81..b8ca47bedd 100644 --- 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 @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Globalization; using System.IO; +using System.Runtime.InteropServices; using System.Text.Json; using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -179,9 +180,11 @@ public class McpToolsCacheService : ITransientDependency try { // On Unix systems, set permissions to 600 (user read/write only) - if (!OperatingSystem.IsWindows()) + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { +#if NET6_0_OR_GREATER File.SetUnixFileMode(filePath, UnixFileMode.UserRead | UnixFileMode.UserWrite); +#endif } // On Windows, the file inherits permissions from the user profile directory, // which is already restrictive to the current user From 0bc6d1cdff68815ef142d24e5a823b71160960ef Mon Sep 17 00:00:00 2001 From: Mansur Besleney Date: Thu, 8 Jan 2026 12:06:18 +0300 Subject: [PATCH 09/26] 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. --- .../Volo/Abp/Cli/CliConsts.cs | 1 + .../Volo/Abp/Cli/CliPaths.cs | 3 +- .../Volo/Abp/Cli/CliService.cs | 18 +- .../Volo/Abp/Cli/Commands/McpCommand.cs | 20 +- .../Abp/Cli/Commands/Services/IMcpLogger.cs | 36 +++ .../Commands/Services/McpHttpClientService.cs | 33 ++- .../Abp/Cli/Commands/Services/McpLogger.cs | 210 ++++++++++++++++++ .../Cli/Commands/Services/McpServerService.cs | 30 +-- .../Commands/Services/McpToolsCacheService.cs | 21 +- 9 files changed, 316 insertions(+), 56 deletions(-) create mode 100644 framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/IMcpLogger.cs create mode 100644 framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpLogger.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 821a78cc14..4aaa111398 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 @@ -21,6 +21,7 @@ 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"; public static class MemoryKeys 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 8ddf99a24e..683770bb98 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 @@ -1,4 +1,4 @@ -using System; +using System; using System.IO; using System.Text; @@ -15,6 +15,7 @@ public static class CliPaths 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 string McpLog => Path.Combine(Log, "mcp.log"); 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/CliService.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/CliService.cs index 42b8c93219..c8c6ba52a1 100644 --- a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/CliService.cs +++ b/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.Abstractions; using NuGet.Versioning; @@ -10,6 +10,7 @@ using System.Reflection; using System.Threading.Tasks; using Volo.Abp.Cli.Args; using Volo.Abp.Cli.Commands; +using Volo.Abp.Cli.Commands.Services; using Volo.Abp.Cli.Memory; using Volo.Abp.Cli.Version; using Volo.Abp.Cli.Utils; @@ -21,8 +22,11 @@ namespace Volo.Abp.Cli; public class CliService : ITransientDependency { + private const string McpLogSource = nameof(CliService); + private readonly MemoryService _memoryService; private readonly ITelemetryService _telemetryService; + private readonly IMcpLogger _mcpLogger; public ILogger Logger { get; set; } protected ICommandLineArgumentParser CommandLineArgumentParser { get; } protected ICommandSelector CommandSelector { get; } @@ -39,7 +43,8 @@ public class CliService : ITransientDependency ICmdHelper cmdHelper, MemoryService memoryService, CliVersionService cliVersionService, - ITelemetryService telemetryService) + ITelemetryService telemetryService, + IMcpLogger mcpLogger) { _memoryService = memoryService; CommandLineArgumentParser = commandLineArgumentParser; @@ -49,6 +54,7 @@ public class CliService : ITransientDependency CmdHelper = cmdHelper; CliVersionService = cliVersionService; _telemetryService = telemetryService; + _mcpLogger = mcpLogger; Logger = NullLogger.Instance; } @@ -92,10 +98,10 @@ public class CliService : ITransientDependency } 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")) { - await Console.Error.WriteLineAsync($"[MCP] Error: {usageException.Message}"); + _mcpLogger.Error(McpLogSource, usageException.Message); } else { @@ -106,10 +112,10 @@ public class CliService : ITransientDependency catch (Exception ex) { 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")) { - await Console.Error.WriteLineAsync($"[MCP] Fatal error: {ex.Message}"); + _mcpLogger.Error(McpLogSource, "Fatal error", ex); } else { diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/McpCommand.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/McpCommand.cs index 7463a8f5a7..d9dba2c0cc 100644 --- a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/McpCommand.cs +++ b/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 { + private const string LogSource = nameof(McpCommand); public const string Name = "mcp"; private readonly AuthService _authService; private readonly IApiKeyService _apiKeyService; private readonly McpServerService _mcpServerService; private readonly McpHttpClientService _mcpHttpClient; + private readonly IMcpLogger _mcpLogger; public ILogger Logger { get; set; } @@ -33,12 +35,14 @@ public class McpCommand : IConsoleCommand, ITransientDependency IApiKeyService apiKeyService, AuthService authService, McpServerService mcpServerService, - McpHttpClientService mcpHttpClient) + McpHttpClientService mcpHttpClient, + IMcpLogger mcpLogger) { _apiKeyService = apiKeyService; _authService = authService; _mcpServerService = mcpServerService; _mcpHttpClient = mcpHttpClient; + _mcpLogger = mcpLogger; Logger = NullLogger.Instance; } @@ -72,17 +76,17 @@ public class McpCommand : IConsoleCommand, ITransientDependency return; } - // Check server health before starting (log to stderr) - await Console.Error.WriteLineAsync("[MCP] Checking ABP.IO MCP Server connection..."); + // Check server health before starting + _mcpLogger.Info(LogSource, "Checking ABP.IO MCP Server connection..."); var isHealthy = await _mcpHttpClient.CheckServerHealthAsync(); if (!isHealthy) { - await Console.Error.WriteLineAsync("[MCP] Warning: 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.Warning(LogSource, "Could not connect to ABP.IO MCP Server. The server might be offline."); + _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(); ConsoleCancelEventHandler cancelHandler = null; @@ -90,7 +94,7 @@ public class McpCommand : IConsoleCommand, ITransientDependency cancelHandler = (sender, e) => { e.Cancel = true; - Console.Error.WriteLine("[MCP] Shutting down ABP MCP Server..."); + _mcpLogger.Info(LogSource, "Shutting down ABP MCP Server..."); try { @@ -114,7 +118,7 @@ public class McpCommand : IConsoleCommand, ITransientDependency } catch (Exception ex) { - await Console.Error.WriteLineAsync($"[MCP] Error running MCP server: {ex.Message}"); + _mcpLogger.Error(LogSource, "Error running MCP server", ex); throw; } finally diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/IMcpLogger.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/IMcpLogger.cs new file mode 100644 index 0000000000..f579420a1e --- /dev/null +++ b/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; + +/// +/// 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. +/// +public interface IMcpLogger +{ + /// + /// Logs a debug message. Only written to file when log level is Debug. + /// + void Debug(string source, string message); + + /// + /// Logs an informational message. Written to file when log level is Debug or Info. + /// + void Info(string source, string message); + + /// + /// Logs a warning message. Written to file and stderr. + /// + void Warning(string source, string message); + + /// + /// Logs an error message. Written to file and stderr. + /// + void Error(string source, string message); + + /// + /// Logs an error message with exception details. Written to file and stderr. + /// + void Error(string source, string message, Exception exception); +} 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 97a4ddab36..dc6bd84521 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 @@ -15,6 +15,7 @@ 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 @@ -26,16 +27,19 @@ public class McpHttpClientService : ITransientDependency private readonly CliHttpClientFactory _httpClientFactory; private readonly ILogger _logger; + private readonly IMcpLogger _mcpLogger; private readonly MemoryService _memoryService; private string _cachedServerUrl; public McpHttpClientService( CliHttpClientFactory httpClientFactory, ILogger logger, + IMcpLogger mcpLogger, MemoryService memoryService) { _httpClientFactory = httpClientFactory; _logger = logger; + _mcpLogger = mcpLogger; _memoryService = memoryService; } @@ -87,8 +91,7 @@ public class McpHttpClientService : ITransientDependency if (!response.IsSuccessStatusCode) { - // Log detailed error to stderr for debugging - await Console.Error.WriteLineAsync($"[MCP] API call failed with status: {response.StatusCode}"); + _mcpLogger.Error(LogSource, $"API call failed with status: {response.StatusCode}"); // Return sanitized error message to client var errorMessage = GetSanitizedHttpErrorMessage(response.StatusCode); @@ -99,24 +102,21 @@ public class McpHttpClientService : ITransientDependency } catch (HttpRequestException ex) { - // Log detailed error to stderr for debugging - await Console.Error.WriteLineAsync($"[MCP] Network error calling tool '{toolName}': {ex.Message}"); + _mcpLogger.Error(LogSource, $"Network error calling tool '{toolName}'", ex); // Return sanitized error to client return CreateErrorResponse(ErrorMessages.NetworkConnectivity); } catch (TaskCanceledException ex) { - // Log detailed error to stderr for debugging - await Console.Error.WriteLineAsync($"[MCP] Timeout calling tool '{toolName}': {ex.Message}"); + _mcpLogger.Error(LogSource, $"Timeout calling tool '{toolName}'", ex); // Return sanitized error to client return CreateErrorResponse(ErrorMessages.Timeout); } catch (Exception ex) { - // Log detailed error to stderr for debugging - await Console.Error.WriteLineAsync($"[MCP] Unexpected error calling tool '{toolName}': {ex.Message}"); + _mcpLogger.Error(LogSource, $"Unexpected error calling tool '{toolName}'", ex); // Return generic sanitized error to client 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.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.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.InternalServerError => "The tool execution encountered an internal error. Please try again later.", _ => "The tool execution failed. Please try again later." @@ -188,8 +188,7 @@ public class McpHttpClientService : ITransientDependency if (!response.IsSuccessStatusCode) { - // Log detailed error to stderr for debugging - await Console.Error.WriteLineAsync($"[MCP] Failed to fetch tool definitions with status: {response.StatusCode}"); + _mcpLogger.Error(LogSource, $"Failed to fetch tool definitions with status: {response.StatusCode}"); // Throw sanitized exception var errorMessage = GetSanitizedHttpErrorMessage(response.StatusCode); @@ -205,24 +204,21 @@ public class McpHttpClientService : ITransientDependency } catch (HttpRequestException ex) { - // Log detailed error to stderr for debugging - await Console.Error.WriteLineAsync($"[MCP] Network error fetching tool definitions: {ex.Message}"); + _mcpLogger.Error(LogSource, "Network error fetching tool definitions", ex); // Throw sanitized exception throw CreateToolDefinitionException("Network connectivity issue. Please check your internet connection and try again."); } catch (TaskCanceledException ex) { - // Log detailed error to stderr for debugging - await Console.Error.WriteLineAsync($"[MCP] Timeout fetching tool definitions: {ex.Message}"); + _mcpLogger.Error(LogSource, "Timeout fetching tool definitions", ex); // Throw sanitized exception throw CreateToolDefinitionException("Request timed out. Please try again."); } catch (JsonException ex) { - // Log detailed error to stderr for debugging - await Console.Error.WriteLineAsync($"[MCP] JSON parsing error: {ex.Message}"); + _mcpLogger.Error(LogSource, "JSON parsing error", ex); // Throw sanitized exception throw CreateToolDefinitionException("Invalid response format received."); @@ -234,8 +230,7 @@ public class McpHttpClientService : ITransientDependency } catch (Exception ex) { - // Log detailed error to stderr for debugging - await Console.Error.WriteLineAsync($"[MCP] Unexpected error fetching tool definitions: {ex.Message}"); + _mcpLogger.Error(LogSource, "Unexpected error fetching tool definitions", ex); // Throw sanitized exception throw CreateToolDefinitionException("An unexpected error occurred. Please try again later."); diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpLogger.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpLogger.cs new file mode 100644 index 0000000000..dd3c0f2f78 --- /dev/null +++ b/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; + +/// +/// 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 +/// +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 + } +} + +/// +/// Log levels for MCP logging. +/// +public enum McpLogLevel +{ + Debug = 0, + Info = 1, + Warning = 2, + Error = 3, + None = 4 +} 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 dff741d11a..c1938498a5 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 @@ -4,7 +4,6 @@ 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; @@ -15,6 +14,8 @@ namespace Volo.Abp.Cli.Commands.Services; public class McpServerService : ITransientDependency { + private const string LogSource = nameof(McpServerService); + private static class ToolErrorMessages { 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 McpToolsCacheService _toolsCacheService; + private readonly IMcpLogger _mcpLogger; public McpServerService( McpHttpClientService mcpHttpClient, - McpToolsCacheService toolsCacheService) + McpToolsCacheService toolsCacheService, + IMcpLogger mcpLogger) { _mcpHttpClient = mcpHttpClient; _toolsCacheService = toolsCacheService; + _mcpLogger = mcpLogger; } public async Task RunAsync(CancellationToken cancellationToken = default) { - // Log to stderr to avoid corrupting stdout JSON-RPC stream - await Console.Error.WriteLineAsync("[MCP] Starting ABP MCP Server (stdio)"); + _mcpLogger.Info(LogSource, "Starting ABP MCP Server (stdio)"); var options = new McpServerOptions(); await RegisterAllToolsAsync(options); // 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( new StdioServerTransport("abp-mcp-server", NullLoggerFactory.Instance), options @@ -50,7 +53,7 @@ public class McpServerService : ITransientDependency 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) @@ -58,7 +61,7 @@ public class McpServerService : ITransientDependency // Get tool definitions from cache (or fetch from server) 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 foreach (var toolDef in toolDefinitions) @@ -129,8 +132,7 @@ public class McpServerService : ITransientDependency JsonSerializer.SerializeToElement(inputSchema), async (context, cancellationToken) => { - // Log to stderr to avoid corrupting stdout JSON-RPC stream - await Console.Error.WriteLineAsync($"[MCP] Tool '{name}' called with arguments: {context.Params.Arguments}"); + _mcpLogger.Debug(LogSource, $"Tool '{name}' called with arguments: {context.Params.Arguments}"); try { @@ -151,11 +153,11 @@ public class McpServerService : ITransientDependency // Check if the HTTP client returned an error if (callToolResult.IsError == true) { - await Console.Error.WriteLineAsync($"[MCP] Tool '{name}' returned an error"); + _mcpLogger.Warning(LogSource, $"Tool '{name}' returned an error"); } else { - await Console.Error.WriteLineAsync($"[MCP] Tool '{name}' executed successfully"); + _mcpLogger.Debug(LogSource, $"Tool '{name}' executed successfully"); } return callToolResult; @@ -163,8 +165,8 @@ public class McpServerService : ITransientDependency } catch (Exception deserializeEx) { - await Console.Error.WriteLineAsync($"[MCP] Failed to deserialize response as CallToolResult: {deserializeEx.Message}"); - await Console.Error.WriteLineAsync($"[MCP] Response was: {resultJson.Substring(0, Math.Min(500, resultJson.Length))}"); + _mcpLogger.Error(LogSource, $"Failed to deserialize response as CallToolResult: {deserializeEx.Message}"); + _mcpLogger.Debug(LogSource, $"Response was: {resultJson.Substring(0, Math.Min(500, resultJson.Length))}"); } // Fallback: return error result if deserialization fails @@ -173,7 +175,7 @@ public class McpServerService : ITransientDependency catch (Exception ex) { // 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 CreateErrorResult(ToolErrorMessages.UnexpectedError); 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 index b8ca47bedd..ec5669d2e8 100644 --- 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 @@ -15,21 +15,26 @@ namespace Volo.Abp.Cli.Commands.Services; public class McpToolsCacheService : ITransientDependency { + private const string LogSource = nameof(McpToolsCacheService); + private readonly McpHttpClientService _mcpHttpClient; private readonly MemoryService _memoryService; private readonly McpToolDefinitionValidator _validator; private readonly ILogger _logger; + private readonly IMcpLogger _mcpLogger; public McpToolsCacheService( McpHttpClientService mcpHttpClient, MemoryService memoryService, McpToolDefinitionValidator validator, - ILogger logger) + ILogger logger, + IMcpLogger mcpLogger) { _mcpHttpClient = mcpHttpClient; _memoryService = memoryService; _validator = validator; _logger = logger; + _mcpLogger = mcpLogger; } public async Task> GetToolDefinitionsAsync() @@ -39,7 +44,7 @@ public class McpToolsCacheService : ITransientDependency var cachedTools = await LoadFromCacheAsync(); if (cachedTools != null) { - await Console.Error.WriteLineAsync("[MCP] Using cached tool definitions"); + _mcpLogger.Debug(LogSource, "Using cached tool definitions"); return cachedTools; } } @@ -47,7 +52,7 @@ public class McpToolsCacheService : ITransientDependency // Cache is invalid or missing, fetch from server try { - await Console.Error.WriteLineAsync("[MCP] Fetching tool definitions from server..."); + _mcpLogger.Info(LogSource, "Fetching tool definitions from server..."); var tools = await _mcpHttpClient.GetToolDefinitionsAsync(); // Validate and filter tool definitions @@ -56,7 +61,7 @@ public class McpToolsCacheService : ITransientDependency 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"); + _mcpLogger.Warning(LogSource, "No valid tool definitions received from server"); return new List(); } @@ -64,24 +69,24 @@ public class McpToolsCacheService : ITransientDependency await SaveToCacheAsync(validTools); 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; } catch (Exception ex) { // 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..."); + _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) { - await Console.Error.WriteLineAsync("[MCP] Using expired cache as fallback"); + _mcpLogger.Info(LogSource, "Using expired cache as fallback"); 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(); } } From ed4f7a74407cd05717d315bd4fa4146cddd3a3d9 Mon Sep 17 00:00:00 2001 From: Mansur Besleney Date: Thu, 8 Jan 2026 13:30:22 +0300 Subject: [PATCH 10/26] Refactor MCP command and service logic for clarity Extracted license validation logic in McpCommand to a dedicated method for reuse and clarity. Improved executable path resolution by splitting logic into helper methods. In McpHttpClientService, centralized HTTP exception handling and replaced repeated code with a single method. Refactored tool invocation in McpServerService to use a dedicated handler, improving error handling and logging. Simplified log level parsing in McpLogger for better maintainability. --- .../Volo/Abp/Cli/Commands/McpCommand.cs | 113 +++++++++++------- .../Commands/Services/McpHttpClientService.cs | 43 ++++--- .../Abp/Cli/Commands/Services/McpLogger.cs | 34 +++--- .../Cli/Commands/Services/McpServerService.cs | 106 ++++++++-------- 4 files changed, 162 insertions(+), 134 deletions(-) diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/McpCommand.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/McpCommand.cs index d9dba2c0cc..49ec0b5067 100644 --- a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/McpCommand.cs +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/McpCommand.cs @@ -48,25 +48,7 @@ public class McpCommand : IConsoleCommand, ITransientDependency public async Task ExecuteAsync(CommandLineArgs commandLineArgs) { - var loginInfo = await _authService.GetLoginInfoAsync(); - - if (string.IsNullOrEmpty(loginInfo?.Organization)) - { - throw new CliUsageException("Please log in with your account!"); - } - - var licenseResult = await _apiKeyService.GetApiKeyOrNullAsync(); - - if (licenseResult == null || !licenseResult.HasActiveLicense) - { - var errorMessage = licenseResult?.ErrorMessage ?? "No active license found."; - throw new CliUsageException(errorMessage); - } - - if (licenseResult.LicenseEndTime.HasValue && licenseResult.LicenseEndTime.Value < DateTime.UtcNow) - { - throw new CliUsageException("Your license has expired. Please renew your license to use the MCP server."); - } + await ValidateLicenseAsync(); var option = commandLineArgs.Target; @@ -89,9 +71,8 @@ public class McpCommand : IConsoleCommand, ITransientDependency _mcpLogger.Info(LogSource, "Starting ABP MCP Server..."); var cts = new CancellationTokenSource(); - ConsoleCancelEventHandler cancelHandler = null; - cancelHandler = (sender, e) => + ConsoleCancelEventHandler cancelHandler = (sender, e) => { e.Cancel = true; _mcpLogger.Info(LogSource, "Shutting down ABP MCP Server..."); @@ -128,6 +109,29 @@ public class McpCommand : IConsoleCommand, ITransientDependency } } + private async Task ValidateLicenseAsync() + { + var loginInfo = await _authService.GetLoginInfoAsync(); + + if (string.IsNullOrEmpty(loginInfo?.Organization)) + { + throw new CliUsageException("Please log in with your account!"); + } + + var licenseResult = await _apiKeyService.GetApiKeyOrNullAsync(); + + if (licenseResult == null || !licenseResult.HasActiveLicense) + { + var errorMessage = licenseResult?.ErrorMessage ?? "No active license found."; + throw new CliUsageException(errorMessage); + } + + if (licenseResult.LicenseEndTime.HasValue && licenseResult.LicenseEndTime.Value < DateTime.UtcNow) + { + throw new CliUsageException("Your license has expired. Please renew your license to use the MCP server."); + } + } + private Task PrintConfigurationAsync() { var abpCliPath = GetAbpCliExecutablePath(); @@ -158,20 +162,34 @@ public class McpCommand : IConsoleCommand, ITransientDependency private string GetAbpCliExecutablePath() { - // Try to find the abp CLI executable + 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)) + if (!string.IsNullOrEmpty(processPath) && + Path.GetFileName(processPath).StartsWith("abp", StringComparison.OrdinalIgnoreCase)) { - // If running as a published executable - if (Path.GetFileName(processPath).StartsWith("abp", StringComparison.OrdinalIgnoreCase)) - { - return processPath; - } + return processPath; } } } @@ -180,29 +198,34 @@ public class McpCommand : IConsoleCommand, ITransientDependency // Ignore errors getting process path } - // Check if abp is in PATH + return null; + } + + private string TryGetExecutablePathFromEnvironmentPath() + { var pathEnv = Environment.GetEnvironmentVariable("PATH"); - if (!string.IsNullOrEmpty(pathEnv)) + if (string.IsNullOrEmpty(pathEnv)) { - var paths = pathEnv.Split(Path.PathSeparator); - foreach (var path in paths) + return null; + } + + var paths = pathEnv.Split(Path.PathSeparator); + foreach (var path in paths) + { + var abpPath = Path.Combine(path, "abp.exe"); + if (File.Exists(abpPath)) { - 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 abpPath; + } + + abpPath = Path.Combine(path, "abp"); + if (File.Exists(abpPath)) + { + return abpPath; } } - // Default to "abp" and let the system resolve it - return "abp"; + return null; } public string GetUsageInfo() 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 dc6bd84521..2a4c18bc88 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 @@ -16,6 +16,8 @@ namespace Volo.Abp.Cli.Commands.Services; public class McpHttpClientService : ITransientDependency { private const string LogSource = nameof(McpHttpClientService); + // TODO: Remove hardcoded URL after testing + private const string TestServerUrl = "http://localhost:5100"; private static readonly JsonSerializerOptions JsonSerializerOptionsWeb = new(JsonSerializerDefaults.Web); private static class ErrorMessages @@ -74,7 +76,7 @@ public class McpHttpClientService : ITransientDependency public async Task CallToolAsync(string toolName, JsonElement arguments) { - var baseUrl = "http://localhost:5100";//await GetMcpServerUrlAsync(); + var baseUrl = TestServerUrl;//await GetMcpServerUrlAsync(); var url = $"{baseUrl}/tools/call"; try @@ -161,7 +163,7 @@ public class McpHttpClientService : ITransientDependency public async Task CheckServerHealthAsync() { - var baseUrl = "http://localhost:5100";//await GetMcpServerUrlAsync(); + var baseUrl = TestServerUrl;//await GetMcpServerUrlAsync(); try { @@ -178,7 +180,7 @@ public class McpHttpClientService : ITransientDependency public async Task> GetToolDefinitionsAsync() { - var baseUrl = "http://localhost:5100";//await GetMcpServerUrlAsync(); + var baseUrl = TestServerUrl;//await GetMcpServerUrlAsync(); var url = $"{baseUrl}/tools"; try @@ -204,24 +206,15 @@ public class McpHttpClientService : ITransientDependency } catch (HttpRequestException ex) { - _mcpLogger.Error(LogSource, "Network error fetching tool definitions", ex); - - // Throw sanitized exception - throw CreateToolDefinitionException("Network connectivity issue. Please check your internet connection and try again."); + throw CreateHttpException(ex, "Network error fetching tool definitions"); } catch (TaskCanceledException ex) { - _mcpLogger.Error(LogSource, "Timeout fetching tool definitions", ex); - - // Throw sanitized exception - throw CreateToolDefinitionException("Request timed out. Please try again."); + throw CreateHttpException(ex, "Timeout fetching tool definitions"); } catch (JsonException ex) { - _mcpLogger.Error(LogSource, "JSON parsing error", ex); - - // Throw sanitized exception - throw CreateToolDefinitionException("Invalid response format received."); + throw CreateHttpException(ex, "JSON parsing error"); } catch (Exception ex) when (ex.Message.StartsWith("Failed to fetch tool definitions:")) { @@ -230,13 +223,25 @@ public class McpHttpClientService : ITransientDependency } catch (Exception ex) { - _mcpLogger.Error(LogSource, "Unexpected error fetching tool definitions", ex); - - // Throw sanitized exception - throw CreateToolDefinitionException("An unexpected error occurred. Please try again later."); + throw CreateHttpException(ex, "Unexpected error fetching tool definitions"); } } + private Exception CreateHttpException(Exception ex, string context) + { + _mcpLogger.Error(LogSource, context, ex); + + var userMessage = ex switch + { + HttpRequestException => "Network connectivity issue. Please check your internet connection and try again.", + TaskCanceledException => "Request timed out. Please try again.", + JsonException => "Invalid response format received.", + _ => "An unexpected error occurred. Please try again later." + }; + + return CreateToolDefinitionException(userMessage); + } + private class McpToolsResponse { public List Tools { get; set; } diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpLogger.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpLogger.cs index dd3c0f2f78..1a81486241 100644 --- a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpLogger.cs +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpLogger.cs @@ -157,43 +157,39 @@ public class McpLogger : IMcpLogger, ISingletonDependency private static McpLogLevel GetConfiguredLogLevel() { + var envValue = Environment.GetEnvironmentVariable(CliConsts.McpLogLevelEnvironmentVariable); + var isEmpty = string.IsNullOrWhiteSpace(envValue); + #if DEBUG // In development builds, allow full control via environment variable - var envValue = Environment.GetEnvironmentVariable(CliConsts.McpLogLevelEnvironmentVariable); - - if (string.IsNullOrWhiteSpace(envValue)) + if (isEmpty) { 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 - }; + + return ParseLogLevel(envValue, allowDebug: true); #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)) + if (isEmpty) { return McpLogLevel.Info; // Default level } - return envValue.ToLowerInvariant() switch + return ParseLogLevel(envValue, allowDebug: false); +#endif + } + + private static McpLogLevel ParseLogLevel(string value, bool allowDebug) + { + return value.ToLowerInvariant() switch { - "debug" => McpLogLevel.Info, // Cap Debug to Info + "debug" => allowDebug ? McpLogLevel.Debug : McpLogLevel.Info, "info" => McpLogLevel.Info, "warning" => McpLogLevel.Warning, "error" => McpLogLevel.Error, "none" => McpLogLevel.None, _ => McpLogLevel.Info }; -#endif } } 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 c1938498a5..0c8c4ba21d 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 @@ -130,62 +130,66 @@ public class McpServerService : ITransientDependency name, description, JsonSerializer.SerializeToElement(inputSchema), - async (context, cancellationToken) => - { - _mcpLogger.Debug(LogSource, $"Tool '{name}' called with arguments: {context.Params.Arguments}"); - - try - { - var argumentsDict = context.Params.Arguments; - var argumentsJson = JsonSerializer.SerializeToElement(argumentsDict); - var resultJson = await _mcpHttpClient.CallToolAsync( - name, - argumentsJson - ); - - // Try to deserialize the response as CallToolResult - // The HTTP client should return JSON in the format expected by MCP - try - { - var callToolResult = JsonSerializer.Deserialize(resultJson); - if (callToolResult != null) - { - // Check if the HTTP client returned an error - if (callToolResult.IsError == true) - { - _mcpLogger.Warning(LogSource, $"Tool '{name}' returned an error"); - } - else - { - _mcpLogger.Debug(LogSource, $"Tool '{name}' executed successfully"); - } - - return callToolResult; - } - } - catch (Exception deserializeEx) - { - _mcpLogger.Error(LogSource, $"Failed to deserialize response as CallToolResult: {deserializeEx.Message}"); - _mcpLogger.Debug(LogSource, $"Response was: {resultJson.Substring(0, Math.Min(500, resultJson.Length))}"); - } - - // Fallback: return error result if deserialization fails - return CreateErrorResult(ToolErrorMessages.InvalidResponseFormat); - } - catch (Exception ex) - { - // Log detailed error for debugging - _mcpLogger.Error(LogSource, $"Tool '{name}' execution failed", ex); - - // Return sanitized error to client - return CreateErrorResult(ToolErrorMessages.UnexpectedError); - } - } + (context, cancellationToken) => HandleToolInvocationAsync(name, context, cancellationToken) ); options.ToolCollection.Add(tool); } + private async ValueTask HandleToolInvocationAsync( + string toolName, + RequestContext context, + CancellationToken cancellationToken) + { + _mcpLogger.Debug(LogSource, $"Tool '{toolName}' called with arguments: {context.Params.Arguments}"); + + try + { + var argumentsJson = JsonSerializer.SerializeToElement(context.Params.Arguments); + var resultJson = await _mcpHttpClient.CallToolAsync(toolName, argumentsJson); + + var callToolResult = TryDeserializeResult(resultJson, toolName); + if (callToolResult != null) + { + LogToolResult(toolName, callToolResult); + return callToolResult; + } + + return CreateErrorResult(ToolErrorMessages.InvalidResponseFormat); + } + catch (Exception ex) + { + _mcpLogger.Error(LogSource, $"Tool '{toolName}' execution failed", ex); + return CreateErrorResult(ToolErrorMessages.UnexpectedError); + } + } + + private CallToolResult TryDeserializeResult(string resultJson, string toolName) + { + try + { + return JsonSerializer.Deserialize(resultJson); + } + catch (Exception ex) + { + _mcpLogger.Error(LogSource, $"Failed to deserialize response as CallToolResult: {ex.Message}"); + _mcpLogger.Debug(LogSource, $"Response was: {resultJson.Substring(0, Math.Min(500, resultJson.Length))}"); + return null; + } + } + + private void LogToolResult(string toolName, CallToolResult result) + { + if (result.IsError == true) + { + _mcpLogger.Warning(LogSource, $"Tool '{toolName}' returned an error"); + } + else + { + _mcpLogger.Debug(LogSource, $"Tool '{toolName}' executed successfully"); + } + } + private class AbpMcpServerTool : McpServerTool { private readonly string _name; From 2f527aa3f23e7a7e80b081ed46a08901b918f7c4 Mon Sep 17 00:00:00 2001 From: Mansur Besleney Date: Thu, 8 Jan 2026 13:37:35 +0300 Subject: [PATCH 11/26] Rename 'getconfig' option to 'get-config' in McpCommand Updated the command option and help text from 'getconfig' to 'get-config' for consistency and improved readability. --- .../Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/McpCommand.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/McpCommand.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/McpCommand.cs index 49ec0b5067..ca448e7da9 100644 --- a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/McpCommand.cs +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/McpCommand.cs @@ -52,7 +52,7 @@ public class McpCommand : IConsoleCommand, ITransientDependency var option = commandLineArgs.Target; - if (!string.IsNullOrEmpty(option) && option.Equals("getconfig", StringComparison.OrdinalIgnoreCase)) + if (!string.IsNullOrEmpty(option) && option.Equals("get-config", StringComparison.OrdinalIgnoreCase)) { await PrintConfigurationAsync(); return; @@ -240,12 +240,12 @@ public class McpCommand : IConsoleCommand, ITransientDependency sb.AppendLine("Options:"); sb.AppendLine(""); sb.AppendLine(" (start the local MCP server)"); - sb.AppendLine("getconfig (print MCP client configuration as JSON)"); + sb.AppendLine("get-config (print MCP client configuration as JSON)"); sb.AppendLine(""); sb.AppendLine("Examples:"); sb.AppendLine(""); sb.AppendLine(" abp mcp"); - sb.AppendLine(" abp mcp getconfig"); + sb.AppendLine(" abp mcp get-config"); sb.AppendLine(""); return sb.ToString(); From 5b86a85281233a92f225f9a0c14b14cd8fde1e2d Mon Sep 17 00:00:00 2001 From: Mansur Besleney Date: Thu, 8 Jan 2026 13:45:15 +0300 Subject: [PATCH 12/26] Add telemetry tracking to McpCommand execution Introduced ITelemetryService to McpCommand and added activity tracking using ActivityNameConsts.AbpCliCommandsMcp. Also updated ActivityNameConsts to include the new activity name constant for MCP command telemetry. --- .../Volo/Abp/Cli/Commands/McpCommand.cs | 9 ++++++++- .../Internal/Telemetry/Constants/ActivityNameConsts.cs | 3 ++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/McpCommand.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/McpCommand.cs index ca448e7da9..95500c17e0 100644 --- a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/McpCommand.cs +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/McpCommand.cs @@ -15,6 +15,8 @@ using Volo.Abp.Cli.Commands.Models; using Volo.Abp.Cli.Commands.Services; using Volo.Abp.Cli.Licensing; using Volo.Abp.DependencyInjection; +using Volo.Abp.Internal.Telemetry; +using Volo.Abp.Internal.Telemetry.Constants; namespace Volo.Abp.Cli.Commands; @@ -28,6 +30,7 @@ public class McpCommand : IConsoleCommand, ITransientDependency private readonly McpServerService _mcpServerService; private readonly McpHttpClientService _mcpHttpClient; private readonly IMcpLogger _mcpLogger; + private readonly ITelemetryService _telemetryService; public ILogger Logger { get; set; } @@ -36,13 +39,15 @@ public class McpCommand : IConsoleCommand, ITransientDependency AuthService authService, McpServerService mcpServerService, McpHttpClientService mcpHttpClient, - IMcpLogger mcpLogger) + IMcpLogger mcpLogger, + ITelemetryService telemetryService) { _apiKeyService = apiKeyService; _authService = authService; _mcpServerService = mcpServerService; _mcpHttpClient = mcpHttpClient; _mcpLogger = mcpLogger; + _telemetryService = telemetryService; Logger = NullLogger.Instance; } @@ -58,6 +63,8 @@ public class McpCommand : IConsoleCommand, ITransientDependency return; } + await using var _ = _telemetryService.TrackActivityAsync(ActivityNameConsts.AbpCliCommandsMcp); + // Check server health before starting _mcpLogger.Info(LogSource, "Checking ABP.IO MCP Server connection..."); var isHealthy = await _mcpHttpClient.CheckServerHealthAsync(); diff --git a/framework/src/Volo.Abp.Core/Volo/Abp/Internal/Telemetry/Constants/ActivityNameConsts.cs b/framework/src/Volo.Abp.Core/Volo/Abp/Internal/Telemetry/Constants/ActivityNameConsts.cs index 64b22ef78f..aabbf142e3 100644 --- a/framework/src/Volo.Abp.Core/Volo/Abp/Internal/Telemetry/Constants/ActivityNameConsts.cs +++ b/framework/src/Volo.Abp.Core/Volo/Abp/Internal/Telemetry/Constants/ActivityNameConsts.cs @@ -1,4 +1,4 @@ -namespace Volo.Abp.Internal.Telemetry.Constants; +namespace Volo.Abp.Internal.Telemetry.Constants; public static class ActivityNameConsts { @@ -68,6 +68,7 @@ public static class ActivityNameConsts public const string AbpCliCommandsInstallModule = "AbpCli.Comands.InstallModule"; public const string AbpCliCommandsInstallLocalModule = "AbpCli.Comands.InstallLocalModule"; public const string AbpCliCommandsListModules = "AbpCli.Comands.ListModules"; + public const string AbpCliCommandsMcp = "AbpCli.Comands.Mcp"; public const string AbpCliRun = "AbpCli.Run"; public const string AbpCliExit = "AbpCli.Exit"; public const string ApplicationRun = "Application.Run"; From 783a97cff0483fb348e7b382eded6ea6c4ed69b6 Mon Sep 17 00:00:00 2001 From: Mansur Besleney Date: Fri, 9 Jan 2026 11:36:53 +0300 Subject: [PATCH 13/26] Use dynamic server URL in MCP HTTP client service Replaced hardcoded TestServerUrl with awaitable GetMcpServerUrlAsync() in CallToolAsync, CheckServerHealthAsync, and GetToolDefinitionsAsync methods to ensure the correct server URL is used dynamically. --- .../Volo/Abp/Cli/Commands/Services/McpHttpClientService.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 2a4c18bc88..ed848e5326 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 @@ -76,7 +76,7 @@ public class McpHttpClientService : ITransientDependency public async Task CallToolAsync(string toolName, JsonElement arguments) { - var baseUrl = TestServerUrl;//await GetMcpServerUrlAsync(); + var baseUrl = await GetMcpServerUrlAsync(); var url = $"{baseUrl}/tools/call"; try @@ -163,7 +163,7 @@ public class McpHttpClientService : ITransientDependency public async Task CheckServerHealthAsync() { - var baseUrl = TestServerUrl;//await GetMcpServerUrlAsync(); + var baseUrl = await GetMcpServerUrlAsync(); try { @@ -180,7 +180,7 @@ public class McpHttpClientService : ITransientDependency public async Task> GetToolDefinitionsAsync() { - var baseUrl = TestServerUrl;//await GetMcpServerUrlAsync(); + var baseUrl = await GetMcpServerUrlAsync(); var url = $"{baseUrl}/tools"; try From c1e1d927791d9e98779727e52f86dc12224aee5d Mon Sep 17 00:00:00 2001 From: Mansur Besleney Date: Fri, 9 Jan 2026 11:50:29 +0300 Subject: [PATCH 14/26] Update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index a386e86320..7b735c8d1f 100644 --- a/.gitignore +++ b/.gitignore @@ -329,3 +329,4 @@ deploy/_run_all_log.txt templates/**/yarn.lock templates/app-nolayers/aspnet-core/MyCompanyName.MyProjectName.Mvc/Logs/logs.txt templates/module/aspnet-core/src/MyCompanyName.MyProjectName.Web/Properties/launchSettings.json +/framework/.cursor From fa7e2778195953900039aa81a6d91732e7b46fe5 Mon Sep 17 00:00:00 2001 From: Mansur Besleney Date: Fri, 9 Jan 2026 14:37:55 +0300 Subject: [PATCH 15/26] Refactor MCP tool handling and fix telemetry typos Extracted AbpMcpServerTool to its own file and improved tool name validation in McpHttpClientService. Enhanced error handling and logging, made cache validity configurable, and fixed typos in ActivityNameConsts for AbpCli command telemetry constants. --- .../Cli/Commands/Services/AbpMcpServerTool.cs | 43 +++++++++++++ .../Commands/Services/McpHttpClientService.cs | 61 ++++++++++++------- .../Cli/Commands/Services/McpServerService.cs | 43 +++---------- .../Commands/Services/McpToolsCacheService.cs | 7 ++- .../Telemetry/Constants/ActivityNameConsts.cs | 22 +++---- 5 files changed, 104 insertions(+), 72 deletions(-) create mode 100644 framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/AbpMcpServerTool.cs diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/AbpMcpServerTool.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/AbpMcpServerTool.cs new file mode 100644 index 0000000000..71f0f206f6 --- /dev/null +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/AbpMcpServerTool.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace Volo.Abp.Cli.Commands.Services; + +internal class AbpMcpServerTool : McpServerTool +{ + private readonly string _name; + private readonly string _description; + private readonly JsonElement _inputSchema; + private readonly Func, CancellationToken, ValueTask> _handler; + + public AbpMcpServerTool( + string name, + string description, + JsonElement inputSchema, + Func, CancellationToken, ValueTask> handler) + { + _name = name; + _description = description; + _inputSchema = inputSchema; + _handler = handler; + } + + public override Tool ProtocolTool => new Tool + { + Name = _name, + Description = _description, + InputSchema = _inputSchema + }; + + public override IReadOnlyList Metadata => Array.Empty(); + + public override ValueTask InvokeAsync(RequestContext context, CancellationToken cancellationToken) + { + return _handler(context, cancellationToken); + } +} 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 ed848e5326..3aae328bd7 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,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Net; using System.Net.Http; using System.Text; @@ -31,7 +32,8 @@ public class McpHttpClientService : ITransientDependency private readonly ILogger _logger; private readonly IMcpLogger _mcpLogger; private readonly MemoryService _memoryService; - private string _cachedServerUrl; + private readonly Lazy> _cachedServerUrlLazy; + private List _validToolNames; public McpHttpClientService( CliHttpClientFactory httpClientFactory, @@ -43,39 +45,43 @@ public class McpHttpClientService : ITransientDependency _logger = logger; _mcpLogger = mcpLogger; _memoryService = memoryService; + _cachedServerUrlLazy = new Lazy>(GetMcpServerUrlInternalAsync); } private async Task GetMcpServerUrlAsync() { - // Return cached URL if already resolved - if (_cachedServerUrl != null) - { - return _cachedServerUrl; - } + return await _cachedServerUrlLazy.Value; + } + private async Task GetMcpServerUrlInternalAsync() + { // 1. Check environment variable (highest priority) var envUrl = Environment.GetEnvironmentVariable(CliConsts.McpServerUrlEnvironmentVariable); if (!string.IsNullOrWhiteSpace(envUrl)) { - _cachedServerUrl = envUrl.TrimEnd('/'); - return _cachedServerUrl; + return envUrl.TrimEnd('/'); } // 2. Check persisted setting var persistedUrl = await _memoryService.GetAsync(CliConsts.MemoryKeys.McpServerUrl); if (!string.IsNullOrWhiteSpace(persistedUrl)) { - _cachedServerUrl = persistedUrl.TrimEnd('/'); - return _cachedServerUrl; + return persistedUrl.TrimEnd('/'); } // 3. Return default - _cachedServerUrl = CliConsts.DefaultMcpServerUrl; - return _cachedServerUrl; + return CliConsts.DefaultMcpServerUrl; } public async Task CallToolAsync(string toolName, JsonElement arguments) { + // Validate toolName against whitelist to prevent malicious input + if (_validToolNames != null && !_validToolNames.Contains(toolName)) + { + _mcpLogger.Warning(LogSource, $"Attempted to call unknown tool: {toolName}"); + return CreateErrorResponse($"Unknown tool: {toolName}"); + } + var baseUrl = await GetMcpServerUrlAsync(); var url = $"{baseUrl}/tools/call"; @@ -141,9 +147,14 @@ public class McpHttpClientService : ITransientDependency }, JsonSerializerOptionsWeb); } - private Exception CreateToolDefinitionException(string userMessage) + private CliUsageException CreateToolDefinitionException(string userMessage) { - return new Exception($"Failed to fetch tool definitions: {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) @@ -201,33 +212,37 @@ public class McpHttpClientService : ITransientDependency // The API returns { tools: [...] } format var result = JsonSerializer.Deserialize(responseContent, JsonSerializerOptionsWeb); - - return result?.Tools ?? new List(); + var tools = result?.Tools ?? new List(); + + // Cache tool names for validation + _validToolNames = tools.Select(t => t.Name).ToList(); + + return tools; } catch (HttpRequestException ex) { - throw CreateHttpException(ex, "Network error fetching tool definitions"); + throw CreateHttpExceptionWithInner(ex, "Network error fetching tool definitions"); } catch (TaskCanceledException ex) { - throw CreateHttpException(ex, "Timeout fetching tool definitions"); + throw CreateHttpExceptionWithInner(ex, "Timeout fetching tool definitions"); } catch (JsonException ex) { - throw CreateHttpException(ex, "JSON parsing error"); + throw CreateHttpExceptionWithInner(ex, "JSON parsing error"); } - catch (Exception ex) when (ex.Message.StartsWith("Failed to fetch tool definitions:")) + catch (CliUsageException) { // Already sanitized, rethrow as-is throw; } catch (Exception ex) { - throw CreateHttpException(ex, "Unexpected error fetching tool definitions"); + throw CreateHttpExceptionWithInner(ex, "Unexpected error fetching tool definitions"); } } - private Exception CreateHttpException(Exception ex, string context) + private CliUsageException CreateHttpExceptionWithInner(Exception ex, string context) { _mcpLogger.Error(LogSource, context, ex); @@ -239,7 +254,7 @@ public class McpHttpClientService : ITransientDependency _ => "An unexpected error occurred. Please try again later." }; - return CreateToolDefinitionException(userMessage); + return CreateToolDefinitionException(userMessage, ex); } private class McpToolsResponse 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 0c8c4ba21d..30370b8bc6 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 @@ -15,6 +15,7 @@ namespace Volo.Abp.Cli.Commands.Services; public class McpServerService : ITransientDependency { private const string LogSource = nameof(McpServerService); + private const int MaxLogResponseLength = 500; private static class ToolErrorMessages { @@ -173,7 +174,12 @@ public class McpServerService : ITransientDependency catch (Exception ex) { _mcpLogger.Error(LogSource, $"Failed to deserialize response as CallToolResult: {ex.Message}"); - _mcpLogger.Debug(LogSource, $"Response was: {resultJson.Substring(0, Math.Min(500, resultJson.Length))}"); + + var logResponse = resultJson.Length <= MaxLogResponseLength + ? resultJson + : resultJson.Substring(0, MaxLogResponseLength); + _mcpLogger.Debug(LogSource, $"Response was: {logResponse}"); + return null; } } @@ -189,39 +195,4 @@ public class McpServerService : ITransientDependency _mcpLogger.Debug(LogSource, $"Tool '{toolName}' executed successfully"); } } - - private class AbpMcpServerTool : McpServerTool - { - private readonly string _name; - private readonly string _description; - private readonly JsonElement _inputSchema; - private readonly Func, CancellationToken, ValueTask> _handler; - - public AbpMcpServerTool( - string name, - string description, - JsonElement inputSchema, - Func, CancellationToken, ValueTask> handler) - { - _name = name; - _description = description; - _inputSchema = inputSchema; - _handler = handler; - } - - public override Tool ProtocolTool => new Tool - { - Name = _name, - Description = _description, - InputSchema = _inputSchema - }; - - public override IReadOnlyList Metadata => Array.Empty(); - - public override ValueTask InvokeAsync(RequestContext context, CancellationToken cancellationToken) - { - return _handler(context, cancellationToken); - } - } - } 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 index ec5669d2e8..741e30a840 100644 --- 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 @@ -16,6 +16,7 @@ namespace Volo.Abp.Cli.Commands.Services; public class McpToolsCacheService : ITransientDependency { private const string LogSource = nameof(McpToolsCacheService); + private const int CacheValidityHours = 24; private readonly McpHttpClientService _mcpHttpClient; private readonly MemoryService _memoryService; @@ -110,8 +111,8 @@ public class McpToolsCacheService : ITransientDependency 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) + // Check if less than configured hours old + if (DateTime.Now.Subtract(lastFetchTime).TotalHours < CacheValidityHours) { return true; } @@ -167,6 +168,8 @@ public class McpToolsCacheService : ITransientDependency PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + // Using synchronous File.WriteAllText is acceptable here since cache writes are not on the critical path + // and we need to support multiple target frameworks File.WriteAllText(CliPaths.McpToolsCache, json); // Set restrictive file permissions (user read/write only) diff --git a/framework/src/Volo.Abp.Core/Volo/Abp/Internal/Telemetry/Constants/ActivityNameConsts.cs b/framework/src/Volo.Abp.Core/Volo/Abp/Internal/Telemetry/Constants/ActivityNameConsts.cs index aabbf142e3..fa6d41a9a7 100644 --- a/framework/src/Volo.Abp.Core/Volo/Abp/Internal/Telemetry/Constants/ActivityNameConsts.cs +++ b/framework/src/Volo.Abp.Core/Volo/Abp/Internal/Telemetry/Constants/ActivityNameConsts.cs @@ -58,17 +58,17 @@ public static class ActivityNameConsts public const string AbpStudioSuiteOpen = "AbpStudio.Suite.Open"; public const string AbpStudioGlobalSecretsManage = "AbpStudio.GlobalSecrets.Manage"; public const string AbpStudioGlobalMetadataManage = "AbpStudio.GlobalMetadata.Manage"; - public const string AbpCliCommandsNewSolution = "AbpCli.Comands.NewSolution"; - public const string AbpCliCommandsNewModule = "AbpCli.Comands.NewModule"; - public const string AbpCliCommandsNewPackage = "AbpCli.Comands.NewPackage"; - public const string AbpCliCommandsUpdate = "AbpCli.Comands.Update"; - public const string AbpCliCommandsClean = "AbpCli.Comands.Clean"; - public const string AbpCliCommandsAddPackage = "AbpCli.Comands.AddPackage"; - public const string AbpCliCommandsAddPackageRef = "AbpCli.Comands.AddPackageRef"; - public const string AbpCliCommandsInstallModule = "AbpCli.Comands.InstallModule"; - public const string AbpCliCommandsInstallLocalModule = "AbpCli.Comands.InstallLocalModule"; - public const string AbpCliCommandsListModules = "AbpCli.Comands.ListModules"; - public const string AbpCliCommandsMcp = "AbpCli.Comands.Mcp"; + public const string AbpCliCommandsNewSolution = "AbpCli.Commands.NewSolution"; + public const string AbpCliCommandsNewModule = "AbpCli.Commands.NewModule"; + public const string AbpCliCommandsNewPackage = "AbpCli.Commands.NewPackage"; + public const string AbpCliCommandsUpdate = "AbpCli.Commands.Update"; + public const string AbpCliCommandsClean = "AbpCli.Commands.Clean"; + public const string AbpCliCommandsAddPackage = "AbpCli.Commands.AddPackage"; + public const string AbpCliCommandsAddPackageRef = "AbpCli.Commands.AddPackageRef"; + public const string AbpCliCommandsInstallModule = "AbpCli.Commands.InstallModule"; + public const string AbpCliCommandsInstallLocalModule = "AbpCli.Commands.InstallLocalModule"; + public const string AbpCliCommandsListModules = "AbpCli.Commands.ListModules"; + public const string AbpCliCommandsMcp = "AbpCli.Commands.Mcp"; public const string AbpCliRun = "AbpCli.Run"; public const string AbpCliExit = "AbpCli.Exit"; public const string ApplicationRun = "Application.Run"; From 1fad743faaf7ad336831273f24a89142c79a77de Mon Sep 17 00:00:00 2001 From: Mansur Besleney Date: Fri, 9 Jan 2026 14:39:00 +0300 Subject: [PATCH 16/26] Remove unused TestServerUrl constant Deleted the hardcoded TestServerUrl constant from McpHttpClientService as it is no longer needed after testing. --- .../Volo/Abp/Cli/Commands/Services/McpHttpClientService.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 3aae328bd7..6c3ba356af 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 @@ -17,8 +17,7 @@ namespace Volo.Abp.Cli.Commands.Services; public class McpHttpClientService : ITransientDependency { private const string LogSource = nameof(McpHttpClientService); - // TODO: Remove hardcoded URL after testing - private const string TestServerUrl = "http://localhost:5100"; + private static readonly JsonSerializerOptions JsonSerializerOptionsWeb = new(JsonSerializerDefaults.Web); private static class ErrorMessages From d63719fb1487b52873528fef002d940191510e8a Mon Sep 17 00:00:00 2001 From: Mansur Besleney Date: Wed, 14 Jan 2026 15:56:15 +0300 Subject: [PATCH 17/26] 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. --- .../Abp/Cli/Args/CommandLineArgsExtensions.cs | 11 ++ .../Volo/Abp/Cli/CliConsts.cs | 2 - .../Volo/Abp/Cli/CliPaths.cs | 1 + .../Volo/Abp/Cli/CliService.cs | 6 +- .../Volo/Abp/Cli/Commands/CommandSelector.cs | 4 +- .../Volo/Abp/Cli/Commands/HelpCommand.cs | 4 +- .../Volo/Abp/Cli/Commands/McpCommand.cs | 80 +------- .../Commands/Services/McpHttpClientService.cs | 68 +++---- .../Cli/Commands/Services/McpServerService.cs | 1 + .../Services/McpToolDefinitionValidator.cs | 181 ------------------ .../Commands/Services/McpToolsCacheService.cs | 55 ++---- 11 files changed, 77 insertions(+), 336 deletions(-) create mode 100644 framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Args/CommandLineArgsExtensions.cs delete mode 100644 framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpToolDefinitionValidator.cs diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Args/CommandLineArgsExtensions.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Args/CommandLineArgsExtensions.cs new file mode 100644 index 0000000000..e9ae5ba77c --- /dev/null +++ b/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); + } +} 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 4aaa111398..43436329fb 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 @@ -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"; } } 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 683770bb98..537c794c15 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 @@ -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"); } diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/CliService.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/CliService.cs index c8c6ba52a1..52bbac8e43 100644 --- a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/CliService.cs +++ b/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); } diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/CommandSelector.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/CommandSelector.cs index cf73aa21a7..e398a7747a 100644 --- a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/CommandSelector.cs +++ b/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); } diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/HelpCommand.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/HelpCommand.cs index 43a36a8b74..b6c1cfba11 100644 --- a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/HelpCommand.cs +++ b/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; diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/McpCommand.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/McpCommand.cs index 95500c17e0..ebf59e2ab6 100644 --- a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/McpCommand.cs +++ b/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 { ["abp"] = new McpServerConfig { - Command = abpCliPath, + Command = "abp", Args = new List { "mcp" }, Env = new Dictionary() } @@ -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(); 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 6c3ba356af..c1314d7ea1 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,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 _logger; private readonly IMcpLogger _mcpLogger; - private readonly MemoryService _memoryService; private readonly Lazy> _cachedServerUrlLazy; private List _validToolNames; + private bool _toolDefinitionsLoaded; public McpHttpClientService( CliHttpClientFactory httpClientFactory, ILogger logger, - IMcpLogger mcpLogger, - MemoryService memoryService) + IMcpLogger mcpLogger) { _httpClientFactory = httpClientFactory; _logger = logger; _mcpLogger = mcpLogger; - _memoryService = memoryService; _cachedServerUrlLazy = new Lazy>(GetMcpServerUrlInternalAsync); } private async Task GetMcpServerUrlAsync() { - return await _cachedServerUrlLazy.Value; + return "http://localhost:5100";//await _cachedServerUrlLazy.Value; } private async Task 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(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 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 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 30370b8bc6..c77443b1f1 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 @@ -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; diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpToolDefinitionValidator.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpToolDefinitionValidator.cs deleted file mode 100644 index 49d8c6afc6..0000000000 --- a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpToolDefinitionValidator.cs +++ /dev/null @@ -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 = ""; - - private static readonly Regex ToolNameRegex = new Regex("^[a-zA-Z0-9_]+$", RegexOptions.Compiled); - private static readonly HashSet ValidTypeValues = new HashSet - { "string", "number", "boolean", "object", "array" }; - - private readonly ILogger _logger; - - public McpToolDefinitionValidator(ILogger logger) - { - _logger = logger; - } - - public List ValidateAndFilter(List tools) - { - if (tools == null || tools.Count == 0) - { - return new List(); - } - - var validTools = new List(); - - 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; - } -} - 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 index 741e30a840..2a24f585d2 100644 --- 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 @@ -20,20 +20,17 @@ public class McpToolsCacheService : ITransientDependency private readonly McpHttpClientService _mcpHttpClient; private readonly MemoryService _memoryService; - private readonly McpToolDefinitionValidator _validator; private readonly ILogger _logger; private readonly IMcpLogger _mcpLogger; public McpToolsCacheService( McpHttpClientService mcpHttpClient, MemoryService memoryService, - McpToolDefinitionValidator validator, ILogger 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(); - } - - // 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(); + 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 IsCacheValidAsync() From 9cb407de7c60a803905dd8675554eb804f35c2d5 Mon Sep 17 00:00:00 2001 From: Mansur Besleney Date: Wed, 14 Jan 2026 16:03:00 +0300 Subject: [PATCH 18/26] Use cached MCP server URL in HttpClientService Replaces the hardcoded localhost URL with the cached server URL in GetMcpServerUrlAsync, enabling dynamic server address resolution. --- .../Volo/Abp/Cli/Commands/Services/McpHttpClientService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 c1314d7ea1..e4c4226248 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 @@ -48,7 +48,7 @@ public class McpHttpClientService : ITransientDependency private async Task GetMcpServerUrlAsync() { - return "http://localhost:5100";//await _cachedServerUrlLazy.Value; + return await _cachedServerUrlLazy.Value; } private async Task GetMcpServerUrlInternalAsync() From 54ff709bb7036230c6b0711d772114381cd72d17 Mon Sep 17 00:00:00 2001 From: Mansur Besleney Date: Thu, 15 Jan 2026 11:48:46 +0300 Subject: [PATCH 19/26] Update ActivityNameConsts.cs --- .../Telemetry/Constants/ActivityNameConsts.cs | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/framework/src/Volo.Abp.Core/Volo/Abp/Internal/Telemetry/Constants/ActivityNameConsts.cs b/framework/src/Volo.Abp.Core/Volo/Abp/Internal/Telemetry/Constants/ActivityNameConsts.cs index fa6d41a9a7..f155560f1a 100644 --- a/framework/src/Volo.Abp.Core/Volo/Abp/Internal/Telemetry/Constants/ActivityNameConsts.cs +++ b/framework/src/Volo.Abp.Core/Volo/Abp/Internal/Telemetry/Constants/ActivityNameConsts.cs @@ -58,16 +58,16 @@ public static class ActivityNameConsts public const string AbpStudioSuiteOpen = "AbpStudio.Suite.Open"; public const string AbpStudioGlobalSecretsManage = "AbpStudio.GlobalSecrets.Manage"; public const string AbpStudioGlobalMetadataManage = "AbpStudio.GlobalMetadata.Manage"; - public const string AbpCliCommandsNewSolution = "AbpCli.Commands.NewSolution"; - public const string AbpCliCommandsNewModule = "AbpCli.Commands.NewModule"; - public const string AbpCliCommandsNewPackage = "AbpCli.Commands.NewPackage"; - public const string AbpCliCommandsUpdate = "AbpCli.Commands.Update"; - public const string AbpCliCommandsClean = "AbpCli.Commands.Clean"; - public const string AbpCliCommandsAddPackage = "AbpCli.Commands.AddPackage"; - public const string AbpCliCommandsAddPackageRef = "AbpCli.Commands.AddPackageRef"; - public const string AbpCliCommandsInstallModule = "AbpCli.Commands.InstallModule"; - public const string AbpCliCommandsInstallLocalModule = "AbpCli.Commands.InstallLocalModule"; - public const string AbpCliCommandsListModules = "AbpCli.Commands.ListModules"; + public const string AbpCliCommandsNewSolution = "AbpCli.Comands.NewSolution"; + public const string AbpCliCommandsNewModule = "AbpCli.Comands.NewModule"; + public const string AbpCliCommandsNewPackage = "AbpCli.Comands.NewPackage"; + public const string AbpCliCommandsUpdate = "AbpCli.Comands.Update"; + public const string AbpCliCommandsClean = "AbpCli.Comands.Clean"; + public const string AbpCliCommandsAddPackage = "AbpCli.Comands.AddPackage"; + public const string AbpCliCommandsAddPackageRef = "AbpCli.Comands.AddPackageRef"; + public const string AbpCliCommandsInstallModule = "AbpCli.Comands.InstallModule"; + public const string AbpCliCommandsInstallLocalModule = "AbpCli.Comands.InstallLocalModule"; + public const string AbpCliCommandsListModules = "AbpCli.Comands.ListModules"; public const string AbpCliCommandsMcp = "AbpCli.Commands.Mcp"; public const string AbpCliRun = "AbpCli.Run"; public const string AbpCliExit = "AbpCli.Exit"; From b24107be6b852af0b1fc77b544c1306ccf5445f3 Mon Sep 17 00:00:00 2001 From: Mansur Besleney Date: Thu, 15 Jan 2026 11:49:13 +0300 Subject: [PATCH 20/26] Update ActivityNameConsts.cs --- .../Volo/Abp/Internal/Telemetry/Constants/ActivityNameConsts.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/framework/src/Volo.Abp.Core/Volo/Abp/Internal/Telemetry/Constants/ActivityNameConsts.cs b/framework/src/Volo.Abp.Core/Volo/Abp/Internal/Telemetry/Constants/ActivityNameConsts.cs index f155560f1a..103f6afd63 100644 --- a/framework/src/Volo.Abp.Core/Volo/Abp/Internal/Telemetry/Constants/ActivityNameConsts.cs +++ b/framework/src/Volo.Abp.Core/Volo/Abp/Internal/Telemetry/Constants/ActivityNameConsts.cs @@ -58,7 +58,7 @@ public static class ActivityNameConsts public const string AbpStudioSuiteOpen = "AbpStudio.Suite.Open"; public const string AbpStudioGlobalSecretsManage = "AbpStudio.GlobalSecrets.Manage"; public const string AbpStudioGlobalMetadataManage = "AbpStudio.GlobalMetadata.Manage"; - public const string AbpCliCommandsNewSolution = "AbpCli.Comands.NewSolution"; + public const string AbpCliCommandsNewSolution = "AbpCli.Comands.NewSolution"; public const string AbpCliCommandsNewModule = "AbpCli.Comands.NewModule"; public const string AbpCliCommandsNewPackage = "AbpCli.Comands.NewPackage"; public const string AbpCliCommandsUpdate = "AbpCli.Comands.Update"; From fc3c21ade4c37e95bdfa80af88d9bbab18917862 Mon Sep 17 00:00:00 2001 From: Mansur Besleney Date: Fri, 16 Jan 2026 11:10:17 +0300 Subject: [PATCH 21/26] Refactor MCP logging and tool initialization logic Refactored McpLogger to use ILogger and Serilog for file logging, removing manual file handling and rotation. Enhanced debug logging in McpHttpClientService and added explicit tool name initialization from cache. Updated Program.cs to use a separate log file for MCP mode. Improved error logging in McpServerService for tool execution failures. --- .../Commands/Services/McpHttpClientService.cs | 15 ++- .../Abp/Cli/Commands/Services/McpLogger.cs | 106 +++++------------- .../Cli/Commands/Services/McpServerService.cs | 2 +- .../Commands/Services/McpToolsCacheService.cs | 2 + .../src/Volo.Abp.Cli/Volo/Abp/Cli/Program.cs | 23 +++- 5 files changed, 59 insertions(+), 89 deletions(-) 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 e4c4226248..b27c3740d6 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 @@ -15,7 +15,7 @@ using Volo.Abp.IO; namespace Volo.Abp.Cli.Commands.Services; -public class McpHttpClientService : ITransientDependency +public class McpHttpClientService : ISingletonDependency { private static readonly JsonSerializerOptions JsonSerializerOptionsWeb = new(JsonSerializerDefaults.Web); @@ -80,8 +80,17 @@ public class McpHttpClientService : ITransientDependency public string ServerUrl { get; set; } } + public void InitializeToolNames(List tools) + { + _validToolNames = tools.Select(t => t.Name).ToList(); + _toolDefinitionsLoaded = true; + _mcpLogger.Debug(LogSource, $"Initialized tool names from cache. Count={tools.Count}, Instance={GetHashCode()}"); + } + public async Task CallToolAsync(string toolName, JsonElement arguments) { + _mcpLogger.Debug(LogSource, $"CallToolAsync called for '{toolName}'. _toolDefinitionsLoaded={_toolDefinitionsLoaded}, Instance={GetHashCode()}"); + if (!_toolDefinitionsLoaded) { throw new CliUsageException("Tool definitions have not been loaded yet. This is an internal error."); @@ -193,6 +202,8 @@ public class McpHttpClientService : ITransientDependency public async Task> GetToolDefinitionsAsync() { + _mcpLogger.Debug(LogSource, $"GetToolDefinitionsAsync called. Instance={GetHashCode()}"); + var baseUrl = await GetMcpServerUrlAsync(); var url = $"{baseUrl}/tools"; @@ -220,6 +231,8 @@ public class McpHttpClientService : ITransientDependency _validToolNames = tools.Select(t => t.Name).ToList(); _toolDefinitionsLoaded = true; + _mcpLogger.Debug(LogSource, $"Tool definitions loaded successfully. _toolDefinitionsLoaded={_toolDefinitionsLoaded}, Tool count={tools.Count}, Instance={GetHashCode()}"); + return tools; } catch (HttpRequestException ex) diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpLogger.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpLogger.cs index 1a81486241..20d6fce469 100644 --- a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpLogger.cs +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpLogger.cs @@ -1,26 +1,25 @@ using System; -using System.IO; -using System.Text; +using Microsoft.Extensions.Logging; using Volo.Abp.DependencyInjection; namespace Volo.Abp.Cli.Commands.Services; /// -/// MCP logger implementation that writes to both file and stderr. -/// - All logs at or above the configured level are written to file +/// MCP logger implementation that writes to both file (via Serilog) and stderr. +/// - All logs at or above the configured level are written to file via ILogger /// - Warning and Error logs are also written to stderr /// - Log level is controlled via ABP_MCP_LOG_LEVEL environment variable /// 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 ILogger _logger; private readonly McpLogLevel _configuredLogLevel; - public McpLogger() + public McpLogger(ILogger logger) { + _logger = logger; _configuredLogLevel = GetConfiguredLogLevel(); } @@ -56,48 +55,34 @@ public class McpLogger : IMcpLogger, ISingletonDependency private void Log(McpLogLevel level, string source, string message) { - if (_configuredLogLevel == McpLogLevel.None) + if (_configuredLogLevel == McpLogLevel.None || level < _configuredLogLevel) { 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}"; + var mcpFormattedMessage = $"{LogPrefix}[{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) + // File logging via Serilog + switch (level) { - WriteToStderr(levelStr, message); + case McpLogLevel.Debug: + _logger.LogDebug(mcpFormattedMessage); + break; + case McpLogLevel.Info: + _logger.LogInformation(mcpFormattedMessage); + break; + case McpLogLevel.Warning: + _logger.LogWarning(mcpFormattedMessage); + break; + case McpLogLevel.Error: + _logger.LogError(mcpFormattedMessage); + break; } - } - private void WriteToFile(string formattedMessage) - { - try - { - lock (_fileLock) - { - EnsureLogDirectoryExists(); - RotateLogFileIfNeeded(); - - File.AppendAllText( - CliPaths.McpLog, - formattedMessage + Environment.NewLine, - Encoding.UTF8); - } - } - catch + // Stderr output for MCP protocol (Warning/Error only) + if (level >= McpLogLevel.Warning) { - // Silently ignore file write errors to not disrupt MCP operations + WriteToStderr(level.ToString().ToUpperInvariant(), message); } } @@ -114,47 +99,6 @@ public class McpLogger : IMcpLogger, ISingletonDependency } } - 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() { var envValue = Environment.GetEnvironmentVariable(CliConsts.McpLogLevelEnvironmentVariable); 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 c77443b1f1..a591c42371 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 @@ -161,7 +161,7 @@ public class McpServerService : ITransientDependency } catch (Exception ex) { - _mcpLogger.Error(LogSource, $"Tool '{toolName}' execution failed", ex); + _mcpLogger.Error(LogSource, $"Tool '{toolName}' execution failed '{ex.Message}'", ex); return CreateErrorResult(ToolErrorMessages.UnexpectedError); } } 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 index 2a24f585d2..146f5e4ba9 100644 --- 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 @@ -43,6 +43,8 @@ public class McpToolsCacheService : ITransientDependency if (cachedTools != null) { _mcpLogger.Debug(LogSource, "Using cached tool definitions"); + // Initialize the HTTP client's tool names list from cache + _mcpHttpClient.InitializeToolNames(cachedTools); return cachedTools; } } diff --git a/framework/src/Volo.Abp.Cli/Volo/Abp/Cli/Program.cs b/framework/src/Volo.Abp.Cli/Volo/Abp/Cli/Program.cs index 61f2ef9de8..58d4d43442 100644 --- a/framework/src/Volo.Abp.Cli/Volo/Abp/Cli/Program.cs +++ b/framework/src/Volo.Abp.Cli/Volo/Abp/Cli/Program.cs @@ -1,4 +1,4 @@ -using System; +using System; using Microsoft.Extensions.DependencyInjection; using Serilog; using Serilog.Events; @@ -15,7 +15,7 @@ public class Program Console.OutputEncoding = System.Text.Encoding.UTF8; var loggerOutputTemplate = "{Message:lj}{NewLine}{Exception}"; - Log.Logger = new LoggerConfiguration() + var config = new LoggerConfiguration() .MinimumLevel.Information() .MinimumLevel.Override("Microsoft", LogEventLevel.Warning) .MinimumLevel.Override("Volo.Abp", LogEventLevel.Warning) @@ -26,10 +26,21 @@ public class Program #else .MinimumLevel.Override("Volo.Abp.Cli", LogEventLevel.Information) #endif - .Enrich.FromLogContext() - .WriteTo.File(Path.Combine(CliPaths.Log, "abp-cli-logs.txt"), outputTemplate: loggerOutputTemplate) - .WriteTo.Console(theme: AnsiConsoleTheme.Sixteen, outputTemplate: loggerOutputTemplate) - .CreateLogger(); + .Enrich.FromLogContext(); + + if (args.Length > 0 && args[0] == "mcp") + { + Log.Logger = config + .WriteTo.File(Path.Combine(CliPaths.Log, "abp-cli-mcp-logs.txt"), outputTemplate: loggerOutputTemplate) + .CreateLogger(); + } + else + { + Log.Logger = config + .WriteTo.File(Path.Combine(CliPaths.Log, "abp-cli-logs.txt"), outputTemplate: loggerOutputTemplate) + .WriteTo.Console(theme: AnsiConsoleTheme.Sixteen, outputTemplate: loggerOutputTemplate) + .CreateLogger(); + } using (var application = AbpApplicationFactory.Create( options => From 39ffd978ded74e60bafb5821b352b13d2dbb4407 Mon Sep 17 00:00:00 2001 From: Mansur Besleney Date: Mon, 19 Jan 2026 15:37:19 +0300 Subject: [PATCH 22/26] Add OutputSchema support to MCP tool definitions Introduces an optional OutputSchema property to McpToolDefinition and updates AbpMcpServerTool and McpServerService to handle and register tools with output schemas. This enables tools to define and expose their output schema alongside input schema for improved contract clarity. --- .../Volo/Abp/Cli/Commands/Models/McpToolDefinition.cs | 2 ++ .../Volo/Abp/Cli/Commands/Services/AbpMcpServerTool.cs | 6 +++++- .../Volo/Abp/Cli/Commands/Services/McpServerService.cs | 6 ++++-- 3 files changed, 11 insertions(+), 3 deletions(-) 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 index 1c76a805b1..787b4e3802 100644 --- 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 @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Text.Json; namespace Volo.Abp.Cli.Commands.Models; @@ -7,6 +8,7 @@ public class McpToolDefinition public string Name { get; set; } public string Description { get; set; } public McpToolInputSchema InputSchema { get; set; } + public JsonElement? OutputSchema { get; set; } } public class McpToolInputSchema diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/AbpMcpServerTool.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/AbpMcpServerTool.cs index 71f0f206f6..6858cf7571 100644 --- a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/AbpMcpServerTool.cs +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/AbpMcpServerTool.cs @@ -13,17 +13,20 @@ internal class AbpMcpServerTool : McpServerTool private readonly string _name; private readonly string _description; private readonly JsonElement _inputSchema; + private readonly JsonElement? _outputSchema; private readonly Func, CancellationToken, ValueTask> _handler; public AbpMcpServerTool( string name, string description, JsonElement inputSchema, + JsonElement? outputSchema, Func, CancellationToken, ValueTask> handler) { _name = name; _description = description; _inputSchema = inputSchema; + _outputSchema = outputSchema; _handler = handler; } @@ -31,7 +34,8 @@ internal class AbpMcpServerTool : McpServerTool { Name = _name, Description = _description, - InputSchema = _inputSchema + InputSchema = _inputSchema, + OutputSchema = _outputSchema }; public override IReadOnlyList Metadata => Array.Empty(); 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 a591c42371..7be6af6688 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 @@ -82,7 +82,7 @@ public class McpServerService : ITransientDependency ["required"] = toolDef.InputSchema?.Required ?? new List() }; - RegisterTool(options, toolDef.Name, toolDef.Description, inputSchemaObject); + RegisterTool(options, toolDef.Name, toolDef.Description, inputSchemaObject, toolDef.OutputSchema); } private Dictionary ConvertProperties(Dictionary properties) @@ -121,7 +121,8 @@ public class McpServerService : ITransientDependency McpServerOptions options, string name, string description, - object inputSchema) + object inputSchema, + JsonElement? outputSchema) { if (options.ToolCollection == null) { @@ -132,6 +133,7 @@ public class McpServerService : ITransientDependency name, description, JsonSerializer.SerializeToElement(inputSchema), + outputSchema, (context, cancellationToken) => HandleToolInvocationAsync(name, context, cancellationToken) ); From 63adb87dd285bbedc63627adfba285c4884fe108 Mon Sep 17 00:00:00 2001 From: Mansur Besleney Date: Tue, 20 Jan 2026 10:26:44 +0300 Subject: [PATCH 23/26] Make 'mcp' argument check case-insensitive Updated the argument comparison for 'mcp' to use case-insensitive matching with StringComparison.OrdinalIgnoreCase. This improves usability by allowing users to enter 'mcp' in any letter case. --- framework/src/Volo.Abp.Cli/Volo/Abp/Cli/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/framework/src/Volo.Abp.Cli/Volo/Abp/Cli/Program.cs b/framework/src/Volo.Abp.Cli/Volo/Abp/Cli/Program.cs index 58d4d43442..54d617c143 100644 --- a/framework/src/Volo.Abp.Cli/Volo/Abp/Cli/Program.cs +++ b/framework/src/Volo.Abp.Cli/Volo/Abp/Cli/Program.cs @@ -28,7 +28,7 @@ public class Program #endif .Enrich.FromLogContext(); - if (args.Length > 0 && args[0] == "mcp") + if (args.Length > 0 && args[0].Equals("mcp", StringComparison.OrdinalIgnoreCase)) { Log.Logger = config .WriteTo.File(Path.Combine(CliPaths.Log, "abp-cli-mcp-logs.txt"), outputTemplate: loggerOutputTemplate) From a2a6cec9c6206b0d29b80ba45ea3e1ce31649d4b Mon Sep 17 00:00:00 2001 From: Mansur Besleney Date: Tue, 20 Jan 2026 11:33:02 +0300 Subject: [PATCH 24/26] Refactor MCP command handling and code organization Removed special-case handling for MCP commands in CommandSelector and HelpCommand to simplify logic. In McpHttpClientService, reorganized private methods and classes for better code structure and maintainability. Made ConvertProperties static in McpServerService and clarified comments regarding MCP JSON schema requirements. --- .../Volo/Abp/Cli/Commands/CommandSelector.cs | 6 - .../Volo/Abp/Cli/Commands/HelpCommand.cs | 8 - .../Commands/Services/McpHttpClientService.cs | 145 +++++++++--------- .../Cli/Commands/Services/McpServerService.cs | 4 +- 4 files changed, 74 insertions(+), 89 deletions(-) diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/CommandSelector.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/CommandSelector.cs index e398a7747a..a409614712 100644 --- a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/CommandSelector.cs +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/CommandSelector.cs @@ -17,12 +17,6 @@ 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.IsMcpCommand()) - { - return Options.Commands.GetOrDefault("mcp") ?? typeof(HelpCommand); - } - if (commandLineArgs.Command.IsNullOrWhiteSpace()) { return typeof(HelpCommand); diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/HelpCommand.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/HelpCommand.cs index b6c1cfba11..c051f6d455 100644 --- a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/HelpCommand.cs +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/HelpCommand.cs @@ -32,14 +32,6 @@ public class HelpCommand : IConsoleCommand, ITransientDependency public Task ExecuteAsync(CommandLineArgs commandLineArgs) { - // 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.IsMcpCommand()) - { - // Silently return - MCP server should handle its own errors - return Task.CompletedTask; - } - if (string.IsNullOrWhiteSpace(commandLineArgs.Target)) { Logger.LogInformation(GetUsageInfo()); 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 b27c3740d6..c15a6c06b6 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 @@ -19,13 +19,6 @@ public class McpHttpClientService : ISingletonDependency { private static readonly JsonSerializerOptions JsonSerializerOptionsWeb = new(JsonSerializerDefaults.Web); - private static class ErrorMessages - { - public const string NetworkConnectivity = "The tool execution failed due to a network connectivity issue. Please check your internet connection and try again."; - public const string Timeout = "The tool execution timed out. The operation took too long to complete. Please try again."; - 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; @@ -46,40 +39,6 @@ public class McpHttpClientService : ISingletonDependency _cachedServerUrlLazy = new Lazy>(GetMcpServerUrlInternalAsync); } - private async Task GetMcpServerUrlAsync() - { - return await _cachedServerUrlLazy.Value; - } - - private async Task GetMcpServerUrlInternalAsync() - { - // Check config file - if (File.Exists(CliPaths.McpConfig)) - { - try - { - var json = await FileHelper.ReadAllTextAsync(CliPaths.McpConfig); - var config = JsonSerializer.Deserialize(json, JsonSerializerOptionsWeb); - if (!string.IsNullOrWhiteSpace(config?.ServerUrl)) - { - return config.ServerUrl.TrimEnd('/'); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to read MCP config file"); - } - } - - // Return default - return CliConsts.DefaultMcpServerUrl; - } - - private class McpConfig - { - public string ServerUrl { get; set; } - } - public void InitializeToolNames(List tools) { _validToolNames = tools.Select(t => t.Name).ToList(); @@ -152,37 +111,6 @@ public class McpHttpClientService : ISingletonDependency } } - private string CreateErrorResponse(string errorMessage) - { - return JsonSerializer.Serialize(new - { - content = new[] - { - new - { - type = "text", - text = errorMessage - } - }, - isError = true - }, JsonSerializerOptionsWeb); - } - - private string GetSanitizedHttpErrorMessage(HttpStatusCode statusCode) - { - return statusCode switch - { - HttpStatusCode.Unauthorized => "Authentication failed. Please ensure you are logged in with a valid account.", - 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.BadRequest => "The tool request was invalid. Please check your input parameters and try 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.InternalServerError => "The tool execution encountered an internal error. Please try again later.", - _ => "The tool execution failed. Please try again later." - }; - } - public async Task CheckServerHealthAsync() { var baseUrl = await GetMcpServerUrlAsync(); @@ -258,6 +186,66 @@ public class McpHttpClientService : ISingletonDependency } } + private async Task GetMcpServerUrlAsync() + { + return await _cachedServerUrlLazy.Value; + } + + private async Task GetMcpServerUrlInternalAsync() + { + // Check config file + if (File.Exists(CliPaths.McpConfig)) + { + try + { + var json = await FileHelper.ReadAllTextAsync(CliPaths.McpConfig); + var config = JsonSerializer.Deserialize(json, JsonSerializerOptionsWeb); + if (!string.IsNullOrWhiteSpace(config?.ServerUrl)) + { + return config.ServerUrl.TrimEnd('/'); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to read MCP config file"); + } + } + + // Return default + return CliConsts.DefaultMcpServerUrl; + } + + private string CreateErrorResponse(string errorMessage) + { + return JsonSerializer.Serialize(new + { + content = new[] + { + new + { + type = "text", + text = errorMessage + } + }, + isError = true + }, JsonSerializerOptionsWeb); + } + + private string GetSanitizedHttpErrorMessage(HttpStatusCode statusCode) + { + return statusCode switch + { + HttpStatusCode.Unauthorized => "Authentication failed. Please ensure you are logged in with a valid account.", + 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.BadRequest => "The tool request was invalid. Please check your input parameters and try 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.InternalServerError => "The tool execution encountered an internal error. Please try again later.", + _ => "The tool execution failed. Please try again later." + }; + } + private CliUsageException CreateHttpExceptionWithInner(Exception ex, string context) { _mcpLogger.Error(LogSource, context, ex); @@ -273,9 +261,20 @@ public class McpHttpClientService : ISingletonDependency return new CliUsageException($"Failed to fetch tool definitions: {userMessage}", ex); } + private static class ErrorMessages + { + public const string NetworkConnectivity = "The tool execution failed due to a network connectivity issue. Please check your internet connection and try again."; + public const string Timeout = "The tool execution timed out. The operation took too long to complete. Please try again."; + public const string Unexpected = "The tool execution failed due to an unexpected error. Please try again later."; + } + + private class McpConfig + { + public string ServerUrl { get; set; } + } + 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 7be6af6688..80fdcd3b7d 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 @@ -74,7 +74,7 @@ public class McpServerService : ITransientDependency private void RegisterToolFromDefinition(McpServerOptions options, McpToolDefinition toolDef) { - // Convert McpToolDefinition to the input schema format expected by MCP + // Build input schema with lowercase keys as required by MCP JSON Schema format var inputSchemaObject = new Dictionary { ["type"] = "object", @@ -85,7 +85,7 @@ public class McpServerService : ITransientDependency RegisterTool(options, toolDef.Name, toolDef.Description, inputSchemaObject, toolDef.OutputSchema); } - private Dictionary ConvertProperties(Dictionary properties) + private static Dictionary ConvertProperties(Dictionary properties) { if (properties == null) { From b63a13a3b0c78e27732bd5ece050c68f3b3b22ca Mon Sep 17 00:00:00 2001 From: Mansur Besleney Date: Tue, 20 Jan 2026 11:42:03 +0300 Subject: [PATCH 25/26] Refactor tool input schema handling and JSON serialization Set default 'Type' property in McpToolInputSchema and pass the schema object directly instead of building a dictionary. Use camelCase JSON serialization for input schemas in McpServerService to ensure correct property naming. --- .../Cli/Commands/Models/McpToolDefinition.cs | 1 + .../Cli/Commands/Services/McpServerService.cs | 36 +++++-------------- 2 files changed, 9 insertions(+), 28 deletions(-) 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 index 787b4e3802..f59776bc0a 100644 --- 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 @@ -13,6 +13,7 @@ public class McpToolDefinition public class McpToolInputSchema { + public string Type { get; set; } = "object"; public Dictionary Properties { get; set; } public List Required { 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 80fdcd3b7d..01c48a1345 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,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -18,6 +17,11 @@ public class McpServerService : ITransientDependency private const string LogSource = nameof(McpServerService); private const int MaxLogResponseLength = 500; + private static readonly JsonSerializerOptions JsonCamelCaseOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + private static class ToolErrorMessages { public const string InvalidResponseFormat = "The tool execution completed but returned an invalid response format. Please try again."; @@ -74,32 +78,8 @@ public class McpServerService : ITransientDependency private void RegisterToolFromDefinition(McpServerOptions options, McpToolDefinition toolDef) { - // Build input schema with lowercase keys as required by MCP JSON Schema format - var inputSchemaObject = new Dictionary - { - ["type"] = "object", - ["properties"] = ConvertProperties(toolDef.InputSchema?.Properties), - ["required"] = toolDef.InputSchema?.Required ?? new List() - }; - - RegisterTool(options, toolDef.Name, toolDef.Description, inputSchemaObject, toolDef.OutputSchema); - } - - private static Dictionary ConvertProperties(Dictionary properties) - { - if (properties == null) - { - return new Dictionary(); - } - - return properties.ToDictionary( - kvp => kvp.Key, - kvp => (object)new Dictionary - { - ["type"] = kvp.Value.Type, - ["description"] = kvp.Value.Description - } - ); + var inputSchema = toolDef.InputSchema ?? new McpToolInputSchema(); + RegisterTool(options, toolDef.Name, toolDef.Description, inputSchema, toolDef.OutputSchema); } private static CallToolResult CreateErrorResult(string errorMessage) @@ -132,7 +112,7 @@ public class McpServerService : ITransientDependency var tool = new AbpMcpServerTool( name, description, - JsonSerializer.SerializeToElement(inputSchema), + JsonSerializer.SerializeToElement(inputSchema, JsonCamelCaseOptions), outputSchema, (context, cancellationToken) => HandleToolInvocationAsync(name, context, cancellationToken) ); From 8e483d60938de96682638f23447069568590e622 Mon Sep 17 00:00:00 2001 From: Mansur Besleney Date: Wed, 21 Jan 2026 09:11:26 +0300 Subject: [PATCH 26/26] Update .gitignore --- .gitignore | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 7b735c8d1f..d3bc52cfb8 100644 --- a/.gitignore +++ b/.gitignore @@ -328,5 +328,4 @@ deploy/_run_all_log.txt # No commit yarn.lock files in the subfolders of templates directory templates/**/yarn.lock templates/app-nolayers/aspnet-core/MyCompanyName.MyProjectName.Mvc/Logs/logs.txt -templates/module/aspnet-core/src/MyCompanyName.MyProjectName.Web/Properties/launchSettings.json -/framework/.cursor +templates/module/aspnet-core/src/MyCompanyName.MyProjectName.Web/Properties/launchSettings.json \ No newline at end of file