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