From 82552a6c1f1479ce255fff5edc08b651de75b61f Mon Sep 17 00:00:00 2001 From: Mansur Besleney Date: Mon, 29 Dec 2025 15:02:30 +0300 Subject: [PATCH] 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; } +} +