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
{

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.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<McpCommand> 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;

152
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<McpHttpClientService> _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<bool> 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<McpToolsResponse>(responseContent, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
var result = JsonSerializer.Deserialize<McpToolsResponse>(responseContent, JsonSerializerOptionsWeb);
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;
}
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

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
{
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<ContentBlock>
{
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<CallToolResult>(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<ContentBlock>()
};
// 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<ContentBlock>(),
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);
}
}
);

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.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

Loading…
Cancel
Save