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); + } + } + +}