Browse Source

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.
pull/24677/head
Mansur Besleney 1 month ago
parent
commit
82552a6c1f
  1. 10
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/CliService.cs
  2. 152
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/McpCommand.cs
  3. 23
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Models/McpClientConfiguration.cs
  4. 109
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpHttpClientService.cs
  5. 365
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpServerService.cs

10
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) public async Task RunAsync(string[] args)
{ {
var currentCliVersion = await CliVersionService.GetCurrentCliVersionAsync();
Logger.LogInformation($"ABP CLI {currentCliVersion}");
var commandLineArgs = CommandLineArgumentParser.Parse(args); 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 !DEBUG
if (!commandLineArgs.Options.ContainsKey("skip-cli-version-check")) if (!commandLineArgs.Options.ContainsKey("skip-cli-version-check"))

152
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;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Volo.Abp.Cli.Args; 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.Services; using Volo.Abp.Cli.Commands.Services;
using Volo.Abp.DependencyInjection; using Volo.Abp.DependencyInjection;
@ -15,15 +23,21 @@ public class McpCommand : IConsoleCommand, ITransientDependency
private readonly AuthService _authService; private readonly AuthService _authService;
private readonly AbpNuGetIndexUrlService _nuGetIndexUrlService; private readonly AbpNuGetIndexUrlService _nuGetIndexUrlService;
private readonly McpServerService _mcpServerService;
private readonly McpHttpClientService _mcpHttpClient;
public ILogger<McpCommand> Logger { get; set; } public ILogger<McpCommand> Logger { get; set; }
public McpCommand( public McpCommand(
AbpNuGetIndexUrlService nuGetIndexUrlService, AbpNuGetIndexUrlService nuGetIndexUrlService,
AuthService authService, ILogger<McpCommand> logger) AuthService authService,
McpServerService mcpServerService,
McpHttpClientService mcpHttpClient)
{ {
_nuGetIndexUrlService = nuGetIndexUrlService; _nuGetIndexUrlService = nuGetIndexUrlService;
_authService = authService; _authService = authService;
_mcpServerService = mcpServerService;
_mcpHttpClient = mcpHttpClient;
Logger = NullLogger<McpCommand>.Instance; Logger = NullLogger<McpCommand>.Instance;
} }
@ -42,8 +56,142 @@ public class McpCommand : IConsoleCommand, ITransientDependency
{ {
throw new CliUsageException("Could not find Nuget Index Url!"); 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<string, McpServerConfig>
{
["abp"] = new McpServerConfig
{
Command = abpCliPath,
Args = new List<string> { "mcp" },
Env = new Dictionary<string, string>()
}
}
};
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() public string GetUsageInfo()

23
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<string, McpServerConfig> McpServers { get; set; } = new();
}
public class McpServerConfig
{
[JsonPropertyName("command")]
public string Command { get; set; }
[JsonPropertyName("args")]
public List<string> Args { get; set; } = new();
[JsonPropertyName("env")]
public Dictionary<string, string> Env { get; set; }
}

109
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<McpHttpClientService> _logger;
private const string DefaultMcpServerUrl = "https://mcp.abp.io";
private const string LocalMcpServerUrl = "http://localhost:5100";
public McpHttpClientService(
CliHttpClientFactory httpClientFactory,
ILogger<McpHttpClientService> logger)
{
_httpClientFactory = httpClientFactory;
_logger = logger;
}
public async Task<string> 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<bool> 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;
}
}
}

365
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<McpServerService> _logger;
public McpServerService(
McpHttpClientService mcpHttpClient,
ILogger<McpServerService> 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<McpRequest>(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<McpResponse> 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<JsonElement>(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; }
}
Loading…
Cancel
Save