Browse Source

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.
pull/24677/head
Mansur Besleney 4 weeks ago
parent
commit
145a3cd524
  1. 2
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/CliConsts.cs
  2. 21
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/McpCommand.cs
  3. 152
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpHttpClientService.cs
  4. 51
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpServerService.cs
  5. 5
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpToolsCacheService.cs

2
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 public static class CliConsts
{ {

21
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.Auth;
using Volo.Abp.Cli.Commands.Models; using Volo.Abp.Cli.Commands.Models;
using Volo.Abp.Cli.Commands.Services; using Volo.Abp.Cli.Commands.Services;
using Volo.Abp.Cli.Licensing;
using Volo.Abp.DependencyInjection; using Volo.Abp.DependencyInjection;
namespace Volo.Abp.Cli.Commands; namespace Volo.Abp.Cli.Commands;
@ -22,19 +23,19 @@ public class McpCommand : IConsoleCommand, ITransientDependency
public const string Name = "mcp"; public const string Name = "mcp";
private readonly AuthService _authService; private readonly AuthService _authService;
private readonly AbpNuGetIndexUrlService _nuGetIndexUrlService; private readonly IApiKeyService _apiKeyService;
private readonly McpServerService _mcpServerService; private readonly McpServerService _mcpServerService;
private readonly McpHttpClientService _mcpHttpClient; private readonly McpHttpClientService _mcpHttpClient;
public ILogger<McpCommand> Logger { get; set; } public ILogger<McpCommand> Logger { get; set; }
public McpCommand( public McpCommand(
AbpNuGetIndexUrlService nuGetIndexUrlService, IApiKeyService apiKeyService,
AuthService authService, AuthService authService,
McpServerService mcpServerService, McpServerService mcpServerService,
McpHttpClientService mcpHttpClient) McpHttpClientService mcpHttpClient)
{ {
_nuGetIndexUrlService = nuGetIndexUrlService; _apiKeyService = apiKeyService;
_authService = authService; _authService = authService;
_mcpServerService = mcpServerService; _mcpServerService = mcpServerService;
_mcpHttpClient = mcpHttpClient; _mcpHttpClient = mcpHttpClient;
@ -50,11 +51,17 @@ public class McpCommand : IConsoleCommand, ITransientDependency
throw new CliUsageException("Please log in with your account!"); throw new CliUsageException("Please log in with your account!");
} }
var nugetIndexUrl = await _nuGetIndexUrlService.GetAsync(); var licenseResult = await _apiKeyService.GetApiKeyOrNullAsync();
if (nugetIndexUrl == null) 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; var option = commandLineArgs.Target;

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

@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Net;
using System.Net.Http; using System.Net.Http;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
@ -14,6 +15,15 @@ namespace Volo.Abp.Cli.Commands.Services;
public class McpHttpClientService : ITransientDependency 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 CliHttpClientFactory _httpClientFactory;
private readonly ILogger<McpHttpClientService> _logger; private readonly ILogger<McpHttpClientService> _logger;
private readonly MemoryService _memoryService; private readonly MemoryService _memoryService;
@ -67,16 +77,9 @@ public class McpHttpClientService : ITransientDependency
{ {
using var httpClient = _httpClientFactory.CreateClient(needsAuthentication: true); using var httpClient = _httpClientFactory.CreateClient(needsAuthentication: true);
var requestBody = new var jsonContent = JsonSerializer.Serialize(
{ new { name = toolName, arguments },
name = toolName, JsonSerializerOptionsWeb);
arguments = arguments
};
var jsonContent = JsonSerializer.Serialize(requestBody, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
var content = new StringContent(jsonContent, Encoding.UTF8, "application/json"); var content = new StringContent(jsonContent, Encoding.UTF8, "application/json");
@ -84,41 +87,76 @@ public class McpHttpClientService : ITransientDependency
if (!response.IsSuccessStatusCode) 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}"); await Console.Error.WriteLineAsync($"[MCP] API call failed with status: {response.StatusCode}");
return JsonSerializer.Serialize(new // Return sanitized error message to client
{ var errorMessage = GetSanitizedHttpErrorMessage(response.StatusCode);
content = new[] return CreateErrorResponse(errorMessage);
{
new
{
type = "text",
text = $"Error: API call failed with status {response.StatusCode}"
}
}
});
} }
return await response.Content.ReadAsStringAsync(); 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) catch (Exception ex)
{ {
// Log to stderr to avoid corrupting stdout // Log detailed error to stderr for debugging
await Console.Error.WriteLineAsync($"[MCP] Error calling MCP tool '{toolName}': {ex.Message}"); 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 = errorMessage
type = "text",
text = $"Error: {ex.Message}"
}
} }
}); },
} 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<bool> CheckServerHealthAsync() public async Task<bool> CheckServerHealthAsync()
@ -150,26 +188,58 @@ public class McpHttpClientService : ITransientDependency
if (!response.IsSuccessStatusCode) 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}"); 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(); var responseContent = await response.Content.ReadAsStringAsync();
// The API returns { tools: [...] } format // The API returns { tools: [...] } format
var result = JsonSerializer.Deserialize<McpToolsResponse>(responseContent, new JsonSerializerOptions var result = JsonSerializer.Deserialize<McpToolsResponse>(responseContent, JsonSerializerOptionsWeb);
{
PropertyNameCaseInsensitive = true
});
return result?.Tools ?? new List<McpToolDefinition>(); return result?.Tools ?? new List<McpToolDefinition>();
} }
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; 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 private class McpToolsResponse

51
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 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 McpHttpClientService _mcpHttpClient;
private readonly McpToolsCacheService _toolsCacheService; private readonly McpToolsCacheService _toolsCacheService;
@ -91,6 +97,21 @@ public class McpServerService : ITransientDependency
); );
} }
private static CallToolResult CreateErrorResult(string errorMessage)
{
return new CallToolResult
{
Content = new List<ContentBlock>
{
new TextContentBlock
{
Text = errorMessage
}
},
IsError = true
};
}
private void RegisterTool( private void RegisterTool(
McpServerOptions options, McpServerOptions options,
string name, string name,
@ -120,8 +141,6 @@ public class McpServerService : ITransientDependency
argumentsJson argumentsJson
); );
await Console.Error.WriteLineAsync($"[MCP] Tool '{name}' executed successfully");
// Try to deserialize the response as CallToolResult // Try to deserialize the response as CallToolResult
// The HTTP client should return JSON in the format expected by MCP // The HTTP client should return JSON in the format expected by MCP
try try
@ -129,6 +148,16 @@ public class McpServerService : ITransientDependency
var callToolResult = JsonSerializer.Deserialize<CallToolResult>(resultJson); var callToolResult = JsonSerializer.Deserialize<CallToolResult>(resultJson);
if (callToolResult != null) 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; 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))}"); await Console.Error.WriteLineAsync($"[MCP] Response was: {resultJson.Substring(0, Math.Min(500, resultJson.Length))}");
} }
// Fallback: return empty result if deserialization fails // Fallback: return error result if deserialization fails
return new CallToolResult return CreateErrorResult(ToolErrorMessages.InvalidResponseFormat);
{
Content = new List<ContentBlock>()
};
} }
catch (Exception ex) catch (Exception ex)
{ {
await Console.Error.WriteLineAsync($"[MCP] Tool '{name}' execution failed: {ex.Message}"); // Log detailed error for debugging
return new CallToolResult await Console.Error.WriteLineAsync($"[MCP] Tool '{name}' execution failed with exception: {ex.Message}");
{
Content = new List<ContentBlock>(), // Return sanitized error to client
IsError = true return CreateErrorResult(ToolErrorMessages.UnexpectedError);
};
} }
} }
); );

5
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.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
using System.Runtime.InteropServices;
using System.Text.Json; using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -179,9 +180,11 @@ public class McpToolsCacheService : ITransientDependency
try try
{ {
// On Unix systems, set permissions to 600 (user read/write only) // 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); File.SetUnixFileMode(filePath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
#endif
} }
// On Windows, the file inherits permissions from the user profile directory, // On Windows, the file inherits permissions from the user profile directory,
// which is already restrictive to the current user // which is already restrictive to the current user

Loading…
Cancel
Save