From 8a98b527a6e09949c495cc249c04d852ab6fbcec Mon Sep 17 00:00:00 2001 From: vycdev2 Date: Thu, 30 Jul 2026 11:25:22 +0000 Subject: [PATCH 1/3] feat: add MCP server for AI agent interactions Implements an HTTP-based MCP (Model Context Protocol) server that exposes Morpheus bot data and functionality to AI agents through a clean tool-based API. Endpoints: - GET /api/mcp - Server info and available tools - GET /api/mcp/health - Health check - GET /api/mcp/tools - List all available tools with parameter schemas - POST /api/mcp/call/{toolName} - Execute a tool with parameters Tools: - get_user_stats - User statistics (balance, XP, level, messages, quotes) - get_guild_info - Guild/server information with settings - get_economy_summary - Economy overview (balances, UBI pool, vault, stocks) - get_activity_overview - Global activity metrics - get_guilds - List all servers with activity stats - get_users - Paginated user list - get_quotes - Paginated quotes with filtering - get_quote_by_id - Single quote details - get_recent_logs - Recent bot logs with severity filtering - get_stock_summary - Stock market gainers/losers - get_leaderboard - Activity leaderboard by XP or messages Closes #3 --- MCP/McpApiExtensions.cs | 331 ++++++++++++++++++ MCP/McpApiOptions.cs | 18 + MCP/McpContracts.cs | 208 +++++++++++ MCP/McpService.cs | 561 ++++++++++++++++++++++++++++++ Morpheus.Tests/McpServiceTests.cs | 419 ++++++++++++++++++++++ Program.cs | 36 +- 6 files changed, 1559 insertions(+), 14 deletions(-) create mode 100644 MCP/McpApiExtensions.cs create mode 100644 MCP/McpApiOptions.cs create mode 100644 MCP/McpContracts.cs create mode 100644 MCP/McpService.cs create mode 100644 Morpheus.Tests/McpServiceTests.cs diff --git a/MCP/McpApiExtensions.cs b/MCP/McpApiExtensions.cs new file mode 100644 index 0000000..adc2e07 --- /dev/null +++ b/MCP/McpApiExtensions.cs @@ -0,0 +1,331 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using System.Text.Json; + +namespace Morpheus.MCP; + +/// +/// Extension methods for registering and mapping the MCP API endpoints. +/// Follows the same pattern as the Dashboard API. +/// +public static class McpApiExtensions +{ + private const string CorsPolicyName = "McpCors"; + + private static readonly IReadOnlyList ToolDefinitions = + [ + new McpToolDefinition( + "get_user_stats", + "Get detailed statistics for a user including balance, XP, messages, level, quotes, and activity.", + [ + new McpToolParameter("userId", "integer", "Internal user ID (optional if discordId is provided)", false), + new McpToolParameter("discordId", "string", "Discord user ID (optional if userId is provided)", false), + ]), + new McpToolDefinition( + "get_guild_info", + "Get information about a Discord server/guild including settings, activity stats, and quote count.", + [ + new McpToolParameter("guildId", "integer", "Internal guild ID (optional if discordId is provided)", false), + new McpToolParameter("discordId", "string", "Discord guild ID (optional if guildId is provided)", false), + ]), + new McpToolDefinition( + "get_economy_summary", + "Get overall economy summary including total balances, UBI pool size, slots vault, and stock market info.", + []), + new McpToolDefinition( + "get_activity_overview", + "Get global activity overview including total messages, XP, active users, and server counts.", + []), + new McpToolDefinition( + "get_guilds", + "List all Discord servers the bot is connected to with their activity stats.", + []), + new McpToolDefinition( + "get_users", + "Get a paginated list of all known users.", + [ + new McpToolParameter("page", "integer", "Page number (default: 1)", false, 1), + new McpToolParameter("limit", "integer", "Results per page (default: 20, max: 100)", false, 20), + ]), + new McpToolDefinition( + "get_quotes", + "Get a paginated list of quotes with optional filtering by guild, sort order, and approval status.", + [ + new McpToolParameter("page", "integer", "Page number (default: 1)", false, 1), + new McpToolParameter("sort", "string", "Sort order: newest, oldest, or score (default: newest)", false, "newest"), + new McpToolParameter("approvedOnly", "boolean", "Only show approved quotes (default: true)", false, true), + new McpToolParameter("guildId", "integer", "Filter by guild ID (optional)", false), + ]), + new McpToolDefinition( + "get_quote_by_id", + "Get detailed information about a specific quote by its ID.", + [ + new McpToolParameter("quoteId", "integer", "Quote ID", true), + ]), + new McpToolDefinition( + "get_recent_logs", + "Get recent bot log entries, optionally filtered by severity level.", + [ + new McpToolParameter("limit", "integer", "Number of entries (default: 20, max: 100)", false, 20), + new McpToolParameter("severity", "string", "Filter by severity: Info, Warning, Error, Verbose, Debug (optional)", false), + ]), + new McpToolDefinition( + "get_stock_summary", + "Get stock market summary including total stocks, top gainers, and top losers.", + [ + new McpToolParameter("limit", "integer", "Number of gainers/losers to return (default: 5)", false, 5), + ]), + new McpToolDefinition( + "get_leaderboard", + "Get activity leaderboard by XP or messages, optionally filtered by guild and time period.", + [ + new McpToolParameter("metric", "string", "Metric: xp or messages (default: xp)", false, "xp"), + new McpToolParameter("guildId", "integer", "Filter by guild ID (optional)", false), + new McpToolParameter("days", "integer", "Lookback period in days (default: 30)", false, 30), + new McpToolParameter("limit", "integer", "Number of entries (default: 10, max: 50)", false, 10), + ]), + ]; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false, + }; + + /// + /// Registers MCP API services in the DI container. + /// + public static IServiceCollection AddMcpApi( + this IServiceCollection services, + McpApiOptions options) + { + services.AddSingleton(options); + services.AddScoped(); + + services.ConfigureHttpJsonOptions(jsonOptions => + { + jsonOptions.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + }); + + services.AddCors(corsOptions => + { + corsOptions.AddPolicy(CorsPolicyName, policy => + { + policy + .AllowAnyOrigin() + .AllowAnyHeader() + .AllowAnyMethod(); + }); + }); + + return services; + } + + /// + /// Maps MCP API endpoints to the application. + /// + public static WebApplication MapMcpApi(this WebApplication app) + { + app.MapGet("/api/mcp", () => Results.Ok(new McpServerInfo( + "Morpheus MCP Server", + "1.0.0", + "/api/mcp/health", + "/api/mcp/tools", + "/api/mcp/call/{toolName}", + [.. ToolDefinitions.Select(t => t.Name)] + ))); + + app.MapGet("/api/mcp/health", (McpApiOptions options) => Results.Ok(new + { + status = "ok", + service = "Morpheus MCP Server", + version = "1.0.0", + startedAtUtc = Utilities.Env.StartTime, + authEnabled = !string.IsNullOrWhiteSpace(options.ApiKey) + })); + + RouteGroupBuilder api = app + .MapGroup("/api/mcp") + .RequireCors(CorsPolicyName); + + // List available tools (MCP protocol discovery) + api.MapGet("/tools", () => Results.Ok(new + { + tools = ToolDefinitions + })); + + // Call a specific tool + api.MapPost("/call/{toolName}", async ( + string toolName, + McpToolCallRequest? request, + McpService mcpService, + CancellationToken cancellationToken) => + { + Dictionary parameters = request?.Params ?? []; + + try + { + object? result = toolName.ToLowerInvariant() switch + { + "get_user_stats" => await mcpService.GetUserStatsAsync( + GetIntParam(parameters, "userId"), + GetULongParam(parameters, "discordId"), + cancellationToken), + + "get_guild_info" => await mcpService.GetGuildInfoAsync( + GetIntParam(parameters, "guildId"), + GetULongParam(parameters, "discordId"), + cancellationToken), + + "get_economy_summary" => await mcpService.GetEconomySummaryAsync(cancellationToken), + + "get_activity_overview" => await mcpService.GetActivityOverviewAsync(cancellationToken), + + "get_guilds" => await mcpService.GetGuildsAsync(cancellationToken), + + "get_users" => await mcpService.GetUsersAsync( + GetIntParam(parameters, "page") ?? 1, + GetIntParam(parameters, "limit") ?? 20, + cancellationToken), + + "get_quotes" => await mcpService.GetQuotesAsync( + GetIntParam(parameters, "page") ?? 1, + GetStringParam(parameters, "sort") ?? "newest", + GetBoolParam(parameters, "approvedOnly") ?? true, + GetIntParam(parameters, "guildId"), + cancellationToken), + + "get_quote_by_id" => await mcpService.GetQuoteByIdAsync( + GetIntParam(parameters, "quoteId") ?? throw new ArgumentException("quoteId is required"), + cancellationToken), + + "get_recent_logs" => await mcpService.GetRecentLogsAsync( + GetIntParam(parameters, "limit") ?? 20, + GetStringParam(parameters, "severity"), + cancellationToken), + + "get_stock_summary" => await mcpService.GetStockSummaryAsync( + GetIntParam(parameters, "limit") ?? 5, + cancellationToken), + + "get_leaderboard" => await mcpService.GetLeaderboardAsync( + GetStringParam(parameters, "metric") ?? "xp", + GetIntParam(parameters, "guildId"), + GetIntParam(parameters, "days") ?? 30, + GetIntParam(parameters, "limit") ?? 10, + cancellationToken), + + _ => null + }; + + if (result is null && toolDefinitionsLut.TryGetValue(toolName.ToLowerInvariant(), out _)) + { + // Known tool but returned null (e.g. entity not found) + return Results.Ok(new McpToolResponse(false, null, "Not found or no parameters provided.")); + } + + if (result is null) + { + return Results.NotFound(new McpToolResponse(false, null, $"Unknown tool: {toolName}")); + } + + return Results.Ok(new McpToolResponse(true, result)); + } + catch (ArgumentException ex) + { + return Results.BadRequest(new McpToolResponse(false, null, ex.Message)); + } + catch (Exception ex) + { + return Results.Ok(new McpToolResponse(false, null, $"Internal error: {ex.Message}")); + } + }); + + return app; + } + + private static readonly Dictionary toolDefinitionsLut = ToolDefinitions + .ToDictionary(t => t.Name, _ => true); + + // ── Parameter Helpers ── + + private static int? GetIntParam(Dictionary parameters, string key) + { + if (!parameters.TryGetValue(key, out object? value) || value is null) + return null; + + if (value is JsonElement je) + { + if (je.ValueKind == JsonValueKind.Number && je.TryGetInt32(out int intVal)) + return intVal; + if (je.ValueKind == JsonValueKind.String && int.TryParse(je.GetString(), out int parsed)) + return parsed; + return null; + } + + if (value is int i) return i; + if (value is long l) return (int)l; + if (value is string s && int.TryParse(s, out int parsedStr)) return parsedStr; + return null; + } + + private static ulong? GetULongParam(Dictionary parameters, string key) + { + if (!parameters.TryGetValue(key, out object? value) || value is null) + return null; + + if (value is JsonElement je) + { + if (je.ValueKind == JsonValueKind.Number && je.TryGetUInt64(out ulong ulVal)) + return ulVal; + if (je.ValueKind == JsonValueKind.String && ulong.TryParse(je.GetString(), out ulong parsed)) + return parsed; + return null; + } + + if (value is ulong ul) return ul; + if (value is string s && ulong.TryParse(s, out ulong parsedStr)) return parsedStr; + return null; + } + + private static string? GetStringParam(Dictionary parameters, string key) + { + if (!parameters.TryGetValue(key, out object? value) || value is null) + return null; + + if (value is JsonElement je) + { + return je.ValueKind switch + { + JsonValueKind.String => je.GetString(), + JsonValueKind.Number => je.GetRawText(), + JsonValueKind.True => "true", + JsonValueKind.False => "false", + _ => null + }; + } + + return value?.ToString(); + } + + private static bool? GetBoolParam(Dictionary parameters, string key) + { + if (!parameters.TryGetValue(key, out object? value) || value is null) + return null; + + if (value is JsonElement je) + { + if (je.ValueKind == JsonValueKind.True) return true; + if (je.ValueKind == JsonValueKind.False) return false; + if (je.ValueKind == JsonValueKind.String && bool.TryParse(je.GetString(), out bool parsed)) + return parsed; + return null; + } + + if (value is bool b) return b; + if (value is string s && bool.TryParse(s, out bool parsedStr)) return parsedStr; + return null; + } +} \ No newline at end of file diff --git a/MCP/McpApiOptions.cs b/MCP/McpApiOptions.cs new file mode 100644 index 0000000..b77354c --- /dev/null +++ b/MCP/McpApiOptions.cs @@ -0,0 +1,18 @@ +using Morpheus.Utilities; + +namespace Morpheus.MCP; + +/// +/// Configuration options for the MCP API server. +/// +public sealed record McpApiOptions( + string Urls, + string ApiKey) +{ + public static McpApiOptions FromEnvironment() + { + string urls = Env.Get("MCP_API_URLS", "http://127.0.0.1:5268"); + string apiKey = Env.Get("MCP_API_KEY", string.Empty); + return new McpApiOptions(urls, apiKey); + } +} \ No newline at end of file diff --git a/MCP/McpContracts.cs b/MCP/McpContracts.cs new file mode 100644 index 0000000..534f7cf --- /dev/null +++ b/MCP/McpContracts.cs @@ -0,0 +1,208 @@ +using System.Text.Json.Serialization; + +namespace Morpheus.MCP; + +/// +/// MCP tool definition returned by the /tools endpoint. +/// Describes a callable tool, its parameters, and purpose. +/// +public sealed record McpToolDefinition( + string Name, + string Description, + IReadOnlyList Parameters); + +/// +/// Describes a single parameter for an MCP tool. +/// +public sealed record McpToolParameter( + string Name, + string Type, + string Description, + bool Required = false, + object? Default = null); + +/// +/// MCP server info response. +/// +public sealed record McpServerInfo( + string Service, + string Version, + string Health, + string Tools, + string Call, + string[] AvailableTools); + +/// +/// Request body for calling an MCP tool. +/// +public sealed record McpToolCallRequest( + [property: JsonPropertyName("params")] + Dictionary? Params); + +/// +/// Successful MCP tool response. +/// +public sealed record McpToolResponse( + bool Success, + object? Data, + string? Error = null); + +/// +/// User stats response. +/// +public sealed record McpUserStats( + int Id, + ulong DiscordId, + string Username, + DateTime CreatedAtUtc, + decimal Balance, + int TotalMessages, + long TotalXp, + int? Level, + int QuoteCount, + int QuoteScore, + int ButtonScore, + DateTime? LastActivityAtUtc); + +/// +/// Guild info response. +/// +public sealed record McpGuildInfo( + int Id, + ulong DiscordId, + string Name, + DateTime CreatedAtUtc, + string Prefix, + int TrackedUsers, + long Messages, + long Xp, + int ApprovedQuotes, + bool UseGlobalQuotes, + bool WelcomeMessages, + bool UseActivityRoles); + +/// +/// Economy summary response. +/// +public sealed record McpEconomySummary( + int TotalUsers, + decimal TotalBalance, + decimal AverageBalance, + decimal UbiPoolSize, + decimal SlotsVaultSize, + int TotalStocks, + decimal TotalStockPortfolioValue); + +/// +/// Activity overview response. +/// +public sealed record McpActivityOverview( + long TotalMessages, + long TotalXp, + int ActiveUsersLast30Days, + long MessagesLast30Days, + long XpLast30Days, + int TotalServers, + int TotalKnownUsers, + DateTime? LastActivityAtUtc); + +/// +/// Server list item. +/// +public sealed record McpServerItem( + int Id, + ulong DiscordId, + string Name, + DateTime CreatedAtUtc, + int TrackedUsers, + long Messages, + long Xp, + int ApprovedQuotes); + +/// +/// User list item. +/// +public sealed record McpUserItem( + int Id, + ulong DiscordId, + string Username, + DateTime CreatedAtUtc, + decimal Balance, + long Messages, + long Xp, + int? Level); + +/// +/// Leaderboard entry. +/// +public sealed record McpLeaderboardEntry( + int Rank, + int UserId, + ulong DiscordId, + string Username, + long Value, + int? Level); + +/// +/// Page of quotes. +/// +public sealed record McpQuotePage( + int Page, + int TotalPages, + int Total, + IReadOnlyList Items); + +/// +/// Single quote item. +/// +public sealed record McpQuoteItem( + int Id, + int GuildId, + int UserId, + string Author, + string Content, + DateTime InsertedAtUtc, + bool Approved, + bool Removed, + int Score); + +/// +/// Quote detail. +/// +public sealed record McpQuoteDetail( + int Id, + int GuildId, + string Content, + DateTime InsertedAtUtc, + bool Approved, + bool Removed, + int TotalScore, + string Author); + +/// +/// Moderation log entry. +/// +public sealed record McpModerationEntry( + long Id, + string Severity, + string Message, + DateTime InsertedAtUtc); + +/// +/// Stock market summary. +/// +public sealed record McpStockSummary( + int TotalStocks, + IReadOnlyList TopGainers, + IReadOnlyList TopLosers); + +/// +/// Stock market item. +/// +public sealed record McpStockItem( + int StockId, + string EntityType, + int EntityId, + string Name, + decimal Price, + decimal DailyChangePercent); \ No newline at end of file diff --git a/MCP/McpService.cs b/MCP/McpService.cs new file mode 100644 index 0000000..c8fb89a --- /dev/null +++ b/MCP/McpService.cs @@ -0,0 +1,561 @@ +using Microsoft.EntityFrameworkCore; +using Morpheus.Database; +using Morpheus.Database.Enums; +using Morpheus.Database.Models; + +namespace Morpheus.MCP; + +/// +/// Service that provides data for the MCP API endpoints. +/// Wraps database queries to expose bot data to AI agents. +/// +public sealed class McpService(DB dbContext) +{ + private const string UbiPoolSettingKey = "ubi_pool"; + private const string SlotsVaultSettingKey = "slots_vault"; + private const decimal SlotsVaultDefaultAmount = 10000.00m; + + /// + /// Gets stats for a specific user by id or discord id. + /// + public async Task GetUserStatsAsync(int? userId, ulong? discordId, CancellationToken ct = default) + { + IQueryable query = dbContext.Users.AsNoTracking(); + + if (userId.HasValue) + query = query.Where(u => u.Id == userId.Value); + else if (discordId.HasValue) + query = query.Where(u => u.DiscordId == discordId.Value); + else + return null; + + User? user = await query.FirstOrDefaultAsync(ct); + if (user == null) return null; + + var levels = await dbContext.UserLevels + .AsNoTracking() + .Where(ul => ul.UserId == user.Id) + .GroupBy(_ => 1) + .Select(g => new + { + Messages = g.Sum(ul => (long)ul.UserMessageCount), + Xp = g.Sum(ul => (long)ul.TotalXp), + MaxLevel = g.Max(ul => (int?)ul.Level) + }) + .FirstOrDefaultAsync(ct); + + int quoteCount = await dbContext.Quotes + .AsNoTracking() + .CountAsync(q => q.UserId == user.Id && !q.Removed, ct); + + int quoteScore = await dbContext.QuoteScores + .AsNoTracking() + .Where(qs => qs.UserId == user.Id) + .SumAsync(qs => (int?)qs.Score, ct) ?? 0; + + int buttonScore = await dbContext.ButtonGamePresses + .AsNoTracking() + .CountAsync(bp => bp.UserId == user.Id, ct); + + DateTime? lastActivity = await dbContext.UserActivity + .AsNoTracking() + .Where(ua => ua.UserId == user.Id) + .Select(ua => (DateTime?)ua.InsertDate) + .MaxAsync(ct); + + return new McpUserStats( + user.Id, + user.DiscordId, + user.Username, + user.InsertDate, + user.Balance, + (int)(levels?.Messages ?? 0), + levels?.Xp ?? 0, + levels?.MaxLevel, + quoteCount, + quoteScore, + buttonScore, + lastActivity); + } + + /// + /// Gets guild info by id or discord id. + /// + public async Task GetGuildInfoAsync(int? guildId, ulong? discordId, CancellationToken ct = default) + { + IQueryable query = dbContext.Guilds.AsNoTracking(); + + if (guildId.HasValue) + query = query.Where(g => g.Id == guildId.Value); + else if (discordId.HasValue) + query = query.Where(g => g.DiscordId == discordId.Value); + else + return null; + + Guild? guild = await query.FirstOrDefaultAsync(ct); + if (guild == null) return null; + + var levels = await dbContext.UserLevels + .AsNoTracking() + .Where(ul => ul.GuildId == guild.Id) + .GroupBy(_ => 1) + .Select(g => new + { + Messages = g.Sum(ul => (long)ul.UserMessageCount), + Xp = g.Sum(ul => (long)ul.TotalXp), + Users = g.Count() + }) + .FirstOrDefaultAsync(ct); + + int approvedQuotes = await dbContext.Quotes + .AsNoTracking() + .CountAsync(q => q.GuildId == guild.Id && q.Approved && !q.Removed, ct); + + return new McpGuildInfo( + guild.Id, + guild.DiscordId, + guild.Name, + guild.InsertDate, + guild.Prefix, + levels?.Users ?? 0, + levels?.Messages ?? 0, + levels?.Xp ?? 0, + approvedQuotes, + guild.UseGlobalQuotes, + guild.WelcomeMessages, + guild.UseActivityRoles); + } + + /// + /// Gets economy summary: total balances, pool, vault, stocks. + /// + public async Task GetEconomySummaryAsync(CancellationToken ct = default) + { + int totalUsers = await dbContext.Users.AsNoTracking().CountAsync(ct); + decimal totalBalance = (await dbContext.Users + .AsNoTracking() + .Select(u => u.Balance) + .ToListAsync(ct)) + .Sum(); + + decimal averageBalance = totalUsers > 0 ? totalBalance / totalUsers : 0m; + + decimal ubiPool = await GetBotSettingDecimalAsync(UbiPoolSettingKey, 0m, ct); + decimal slotsVault = await GetBotSettingDecimalAsync(SlotsVaultSettingKey, SlotsVaultDefaultAmount, ct); + + int totalStocks = await dbContext.Stocks.AsNoTracking().CountAsync(ct); + decimal totalPortfolio = (await dbContext.StockHoldings + .AsNoTracking() + .Include(sh => sh.Stock) + .Select(sh => sh.Shares * sh.Stock!.Price) + .ToListAsync(ct)) + .Sum(); + + return new McpEconomySummary( + totalUsers, + totalBalance, + Math.Round(averageBalance, 2), + ubiPool, + slotsVault, + totalStocks, + Math.Round(totalPortfolio, 2)); + } + + /// + /// Gets a global activity overview. + /// + public async Task GetActivityOverviewAsync(CancellationToken ct = default) + { + DateTime last30Days = DateTime.UtcNow.AddDays(-30); + + var levelTotals = await dbContext.UserLevels + .AsNoTracking() + .GroupBy(_ => 1) + .Select(g => new + { + Messages = g.Sum(ul => (long)ul.UserMessageCount), + Xp = g.Sum(ul => (long)ul.TotalXp) + }) + .FirstOrDefaultAsync(ct); + + long totalMessages = levelTotals?.Messages ?? 0L; + long totalXp = levelTotals?.Xp ?? 0L; + + IQueryable recentActivity = dbContext.UserActivity + .AsNoTracking() + .Where(ua => ua.InsertDate >= last30Days); + + int activeUsers = await recentActivity + .Select(ua => ua.UserId) + .Distinct() + .CountAsync(ct); + + long messagesLast30Days = await recentActivity.LongCountAsync(ct); + long xpLast30Days = await recentActivity + .SumAsync(ua => (long?)ua.XpGained, ct) ?? 0L; + + int totalServers = await dbContext.Guilds.AsNoTracking().CountAsync(ct); + int totalUsers = await dbContext.Users.AsNoTracking().CountAsync(ct); + + DateTime? lastActivity = await dbContext.UserActivity + .AsNoTracking() + .Select(ua => (DateTime?)ua.InsertDate) + .MaxAsync(ct); + + return new McpActivityOverview( + totalMessages, + totalXp, + activeUsers, + messagesLast30Days, + xpLast30Days, + totalServers, + totalUsers, + lastActivity); + } + + /// + /// Gets a list of all servers/guilds. + /// + public async Task> GetGuildsAsync(CancellationToken ct = default) + { + var guilds = await dbContext.Guilds + .AsNoTracking() + .OrderByDescending(g => g.InsertDate) + .ToListAsync(ct); + + var result = new List(guilds.Count); + foreach (Guild guild in guilds) + { + var levels = await dbContext.UserLevels + .AsNoTracking() + .Where(ul => ul.GuildId == guild.Id) + .GroupBy(_ => 1) + .Select(g => new + { + Messages = g.Sum(ul => (long)ul.UserMessageCount), + Xp = g.Sum(ul => (long)ul.TotalXp), + Users = g.Count() + }) + .FirstOrDefaultAsync(ct); + + int approvedQuotes = await dbContext.Quotes + .AsNoTracking() + .CountAsync(q => q.GuildId == guild.Id && q.Approved && !q.Removed, ct); + + result.Add(new McpServerItem( + guild.Id, + guild.DiscordId, + guild.Name, + guild.InsertDate, + levels?.Users ?? 0, + levels?.Messages ?? 0, + levels?.Xp ?? 0, + approvedQuotes)); + } + + return result.AsReadOnly(); + } + + /// + /// Gets a paginated list of users. + /// + public async Task> GetUsersAsync(int page = 1, int limit = 20, CancellationToken ct = default) + { + if (page < 1) page = 1; + if (limit < 1) limit = 20; + if (limit > 100) limit = 100; + + var users = await dbContext.Users + .AsNoTracking() + .OrderByDescending(u => u.InsertDate) + .Skip((page - 1) * limit) + .Take(limit) + .ToListAsync(ct); + + var result = new List(users.Count); + foreach (User user in users) + { + var levels = await dbContext.UserLevels + .AsNoTracking() + .Where(ul => ul.UserId == user.Id) + .GroupBy(_ => 1) + .Select(g => new + { + Messages = g.Sum(ul => (long)ul.UserMessageCount), + Xp = g.Sum(ul => (long)ul.TotalXp), + MaxLevel = g.Max(ul => (int?)ul.Level) + }) + .FirstOrDefaultAsync(ct); + + result.Add(new McpUserItem( + user.Id, + user.DiscordId, + user.Username, + user.InsertDate, + user.Balance, + levels?.Messages ?? 0, + levels?.Xp ?? 0, + levels?.MaxLevel)); + } + + return result.AsReadOnly(); + } + + /// + /// Gets a page of quotes. + /// + public async Task GetQuotesAsync( + int page = 1, + string sort = "newest", + bool approvedOnly = true, + int? guildId = null, + CancellationToken ct = default) + { + IQueryable query = dbContext.Quotes.AsNoTracking().Where(q => !q.Removed); + + if (guildId.HasValue) + query = query.Where(q => q.GuildId == guildId.Value); + + if (approvedOnly) + query = query.Where(q => q.Approved); + + int total = await query.CountAsync(ct); + int totalPages = (int)Math.Ceiling(total / (double)10); + if (totalPages == 0) totalPages = 1; + + if (page < 1) page = 1; + if (page > totalPages) page = totalPages; + + query = sort.ToLowerInvariant() switch + { + "oldest" => query.OrderBy(q => q.InsertDate), + "score" => query.OrderByDescending(q => q.Scores.Sum(s => (int)s.Score)), + _ => query.OrderByDescending(q => q.InsertDate), + }; + + List quotes = await query + .Skip((page - 1) * 10) + .Take(10) + .ToListAsync(ct); + + if (quotes.Count == 0) + return new McpQuotePage(page, totalPages, total, []); + + List quoteIds = [.. quotes.Select(q => q.Id)]; + Dictionary scoreMap = await dbContext.QuoteScores + .AsNoTracking() + .Where(qs => quoteIds.Contains(qs.QuoteId)) + .GroupBy(qs => qs.QuoteId) + .Select(g => new { QuoteId = g.Key, Score = g.Sum(qs => qs.Score) }) + .ToDictionaryAsync(g => g.QuoteId, g => g.Score, ct); + + List userIds = [.. quotes.Select(q => q.UserId).Distinct()]; + Dictionary userMap = await dbContext.Users + .AsNoTracking() + .Where(u => userIds.Contains(u.Id)) + .ToDictionaryAsync(u => u.Id, u => u.Username, ct); + + var items = quotes.Select(q => new McpQuoteItem( + q.Id, + q.GuildId, + q.UserId, + userMap.GetValueOrDefault(q.UserId, "Unknown"), + q.Content ?? string.Empty, + q.InsertDate, + q.Approved, + q.Removed, + scoreMap.GetValueOrDefault(q.Id) + )).ToList(); + + return new McpQuotePage(page, totalPages, total, items.AsReadOnly()); + } + + /// + /// Gets details for a single quote. + /// + public async Task GetQuoteByIdAsync(int quoteId, CancellationToken ct = default) + { + Quote? quote = await dbContext.Quotes + .AsNoTracking() + .FirstOrDefaultAsync(q => q.Id == quoteId && !q.Removed, ct); + + if (quote == null) return null; + + int totalScore = await dbContext.QuoteScores + .AsNoTracking() + .Where(qs => qs.QuoteId == quote.Id) + .SumAsync(qs => (int?)qs.Score, ct) ?? 0; + + string author = await dbContext.Users + .AsNoTracking() + .Where(u => u.Id == quote.UserId) + .Select(u => u.Username) + .FirstOrDefaultAsync(ct) ?? "Unknown"; + + return new McpQuoteDetail( + quote.Id, + quote.GuildId, + quote.Content ?? string.Empty, + quote.InsertDate, + quote.Approved, + quote.Removed, + totalScore, + author); + } + + /// + /// Gets recent moderation/relevant log entries. + /// + public async Task> GetRecentLogsAsync( + int limit = 20, + string? severity = null, + CancellationToken ct = default) + { + IQueryable query = dbContext.Logs.AsNoTracking(); + + if (!string.IsNullOrWhiteSpace(severity)) + { + if (Enum.TryParse(severity, true, out var parsedSeverity)) + query = query.Where(l => l.Severity == (int)parsedSeverity); + } + + if (limit < 1) limit = 20; + if (limit > 100) limit = 100; + + var logs = await query + .OrderByDescending(l => l.InsertDate) + .Take(limit) + .ToListAsync(ct); + + return logs.Select(l => new McpModerationEntry( + l.Id, + ((Discord.LogSeverity)l.Severity).ToString(), + l.Message, + l.InsertDate + )).ToList().AsReadOnly(); + } + + /// + /// Gets stock market summary with top gainers and losers. + /// + public async Task GetStockSummaryAsync(int moverLimit = 5, CancellationToken ct = default) + { + int totalStocks = await dbContext.Stocks.AsNoTracking().CountAsync(ct); + + var stocks = await dbContext.Stocks + .AsNoTracking() + .Where(s => s.Price > 0) + .ToListAsync(ct); + + var gainers = stocks + .Where(s => s.DailyChangePercent > 0) + .OrderByDescending(s => s.DailyChangePercent) + .Take(moverLimit) + .Select(s => new McpStockItem( + s.Id, + s.EntityType.ToString(), + s.EntityId, + ResolveStockName(s), + Math.Round(s.Price, 2), + Math.Round(s.DailyChangePercent, 2))) + .ToList().AsReadOnly(); + + var losers = stocks + .Where(s => s.DailyChangePercent < 0) + .OrderBy(s => s.DailyChangePercent) + .Take(moverLimit) + .Select(s => new McpStockItem( + s.Id, + s.EntityType.ToString(), + s.EntityId, + ResolveStockName(s), + Math.Round(s.Price, 2), + Math.Round(s.DailyChangePercent, 2))) + .ToList().AsReadOnly(); + + return new McpStockSummary(totalStocks, gainers, losers); + } + + /// + /// Gets activity leaderboard data. + /// + public async Task> GetLeaderboardAsync( + string metric = "xp", + int? guildId = null, + int days = 30, + int limit = 10, + CancellationToken ct = default) + { + if (limit < 1) limit = 10; + if (limit > 50) limit = 50; + + DateTime since = DateTime.UtcNow.AddDays(-days); + + IQueryable activityQuery = dbContext.UserActivity + .AsNoTracking() + .Where(ua => ua.InsertDate >= since); + + if (guildId.HasValue) + activityQuery = activityQuery.Where(ua => ua.GuildId == guildId.Value); + + var raw = metric.ToLowerInvariant() switch + { + "messages" => await activityQuery + .GroupBy(ua => ua.UserId) + .Select(g => new { UserId = g.Key, Value = g.LongCount() }) + .OrderByDescending(x => x.Value) + .Take(limit) + .ToListAsync(ct), + _ => await activityQuery + .GroupBy(ua => ua.UserId) + .Select(g => new { UserId = g.Key, Value = g.Sum(ua => (long)ua.XpGained) }) + .OrderByDescending(x => x.Value) + .Take(limit) + .ToListAsync(ct) + }; + + if (raw.Count == 0) + return []; + + List userIds = [.. raw.Select(x => x.UserId)]; + var users = await dbContext.Users + .AsNoTracking() + .Where(u => userIds.Contains(u.Id)) + .ToDictionaryAsync(u => u.Id, ct); + + var levels = await dbContext.UserLevels + .AsNoTracking() + .Where(ul => userIds.Contains(ul.UserId)) + .GroupBy(ul => ul.UserId) + .Select(g => new { UserId = g.Key, MaxLevel = g.Max(ul => (int?)ul.Level) }) + .ToDictionaryAsync(g => g.UserId, g => g.MaxLevel, ct); + + return raw.Select((x, i) => new McpLeaderboardEntry( + i + 1, + x.UserId, + users.GetValueOrDefault(x.UserId)?.DiscordId ?? 0, + users.GetValueOrDefault(x.UserId)?.Username ?? "Unknown", + x.Value, + levels.GetValueOrDefault(x.UserId) + )).ToList().AsReadOnly(); + } + + private async Task GetBotSettingDecimalAsync(string key, decimal defaultValue, CancellationToken ct) + { + BotSetting? setting = await dbContext.BotSettings + .AsNoTracking() + .FirstOrDefaultAsync(s => s.Key == key, ct); + + if (setting == null || string.IsNullOrWhiteSpace(setting.Value)) + return defaultValue; + + return decimal.TryParse(setting.Value, out decimal val) ? val : defaultValue; + } + + private static string ResolveStockName(Stock stock) + { + // For stocks tied to entities, try to provide a meaningful name. + // If the entity isn't loaded, fall back to the stock id. + return $"Stock #{stock.Id} ({stock.EntityType})"; + } +} \ No newline at end of file diff --git a/Morpheus.Tests/McpServiceTests.cs b/Morpheus.Tests/McpServiceTests.cs new file mode 100644 index 0000000..4dcb4d4 --- /dev/null +++ b/Morpheus.Tests/McpServiceTests.cs @@ -0,0 +1,419 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Morpheus.Database; +using Morpheus.Database.Models; +using Morpheus.MCP; + +namespace Morpheus.Tests; + +public class McpServiceTests +{ + [Fact] + public async Task GetUserStatsAsync_ReturnsUserStats_WhenUserExists() + { + await using SqliteTestDb testDb = await CreateSqliteDbAsync(); + (Guild guild, User user) = await SeedBaseAsync(testDb.Db); + + testDb.Db.UserLevels.Add(new UserLevels + { + GuildId = guild.Id, + UserId = user.Id, + TotalXp = 500, + UserMessageCount = 10, + Level = 3 + }); + testDb.Db.UserActivity.Add(new UserActivity + { + GuildId = guild.Id, + UserId = user.Id, + DiscordChannelId = 1, + DiscordMessageId = 2, + XpGained = 50, + MessageLength = 25, + InsertDate = DateTime.UtcNow.AddDays(-1) + }); + await testDb.Db.SaveChangesAsync(); + + McpService service = CreateService(testDb.Db); + + McpUserStats? stats = await service.GetUserStatsAsync(user.Id, null); + + Assert.NotNull(stats); + Assert.Equal(user.Id, stats.Id); + Assert.Equal(user.DiscordId, stats.DiscordId); + Assert.Equal(user.Username, stats.Username); + Assert.Equal(1000m, stats.Balance); + Assert.Equal(10, stats.TotalMessages); + Assert.Equal(500, stats.TotalXp); + Assert.Equal(3, stats.Level); + } + + [Fact] + public async Task GetUserStatsAsync_ReturnsNull_WhenUserNotFound() + { + await using SqliteTestDb testDb = await CreateSqliteDbAsync(); + McpService service = CreateService(testDb.Db); + + McpUserStats? stats = await service.GetUserStatsAsync(999, null); + + Assert.Null(stats); + } + + [Fact] + public async Task GetGuildInfoAsync_ReturnsGuildInfo_WhenGuildExists() + { + await using SqliteTestDb testDb = await CreateSqliteDbAsync(); + (Guild guild, User user) = await SeedBaseAsync(testDb.Db); + + testDb.Db.UserLevels.Add(new UserLevels + { + GuildId = guild.Id, + UserId = user.Id, + TotalXp = 200, + UserMessageCount = 5, + Level = 2 + }); + testDb.Db.Quotes.Add(new Quote + { + GuildId = guild.Id, + UserId = user.Id, + Content = "test quote", + Approved = true + }); + await testDb.Db.SaveChangesAsync(); + + McpService service = CreateService(testDb.Db); + + McpGuildInfo? info = await service.GetGuildInfoAsync(guild.Id, null); + + Assert.NotNull(info); + Assert.Equal(guild.Id, info.Id); + Assert.Equal(guild.DiscordId, info.DiscordId); + Assert.Equal(guild.Name, info.Name); + Assert.Equal(guild.Prefix, info.Prefix); + Assert.Equal(1, info.TrackedUsers); + Assert.Equal(5, info.Messages); + Assert.Equal(200, info.Xp); + Assert.Equal(1, info.ApprovedQuotes); + } + + [Fact] + public async Task GetEconomySummaryAsync_ReturnsSummary() + { + await using SqliteTestDb testDb = await CreateSqliteDbAsync(); + await SeedBaseAsync(testDb.Db); + + // Add a second user with balance + testDb.Db.Users.Add(new User + { + DiscordId = 999, + Username = "user2", + Balance = 500m + }); + await testDb.Db.SaveChangesAsync(); + + McpService service = CreateService(testDb.Db); + + McpEconomySummary summary = await service.GetEconomySummaryAsync(); + + Assert.Equal(2, summary.TotalUsers); + Assert.Equal(1500m, summary.TotalBalance); + Assert.Equal(750m, summary.AverageBalance); + Assert.Equal(0m, summary.UbiPoolSize); + } + + [Fact] + public async Task GetActivityOverviewAsync_ReturnsOverview() + { + await using SqliteTestDb testDb = await CreateSqliteDbAsync(); + (Guild guild, User user) = await SeedBaseAsync(testDb.Db); + + testDb.Db.UserLevels.Add(new UserLevels + { + GuildId = guild.Id, + UserId = user.Id, + TotalXp = 300, + UserMessageCount = 7, + Level = 2 + }); + testDb.Db.UserActivity.Add(new UserActivity + { + GuildId = guild.Id, + UserId = user.Id, + DiscordChannelId = 1, + DiscordMessageId = 2, + XpGained = 100, + MessageLength = 50, + InsertDate = DateTime.UtcNow.AddHours(-1) + }); + await testDb.Db.SaveChangesAsync(); + + McpService service = CreateService(testDb.Db); + + McpActivityOverview overview = await service.GetActivityOverviewAsync(); + + Assert.Equal(7, overview.TotalMessages); + Assert.Equal(300, overview.TotalXp); + Assert.Equal(1, overview.ActiveUsersLast30Days); + Assert.Equal(1, overview.MessagesLast30Days); + Assert.Equal(100, overview.XpLast30Days); + Assert.Equal(1, overview.TotalServers); + Assert.Equal(1, overview.TotalKnownUsers); + } + + [Fact] + public async Task GetGuildsAsync_ReturnsGuildList() + { + await using SqliteTestDb testDb = await CreateSqliteDbAsync(); + await SeedBaseAsync(testDb.Db); + + // Add a second guild + testDb.Db.Guilds.Add(new Guild + { + DiscordId = 222, + Name = "Server Two" + }); + await testDb.Db.SaveChangesAsync(); + + McpService service = CreateService(testDb.Db); + + IReadOnlyList guilds = await service.GetGuildsAsync(); + + Assert.Equal(2, guilds.Count); + } + + [Fact] + public async Task GetUsersAsync_ReturnsPaginatedUsers() + { + await using SqliteTestDb testDb = await CreateSqliteDbAsync(); + await SeedBaseAsync(testDb.Db); + + McpService service = CreateService(testDb.Db); + + IReadOnlyList users = await service.GetUsersAsync(page: 1, limit: 10); + + Assert.Single(users); + Assert.Equal(1000m, users[0].Balance); + } + + [Fact] + public async Task GetQuotesAsync_ReturnsQuotes() + { + await using SqliteTestDb testDb = await CreateSqliteDbAsync(); + (Guild guild, User user) = await SeedBaseAsync(testDb.Db); + + testDb.Db.Quotes.Add(new Quote + { + GuildId = guild.Id, + UserId = user.Id, + Content = "Hello world", + Approved = true + }); + testDb.Db.Quotes.Add(new Quote + { + GuildId = guild.Id, + UserId = user.Id, + Content = "Second quote", + Approved = false + }); + await testDb.Db.SaveChangesAsync(); + + McpService service = CreateService(testDb.Db); + + // Get approved only + McpQuotePage page = await service.GetQuotesAsync(approvedOnly: true); + + Assert.Equal(1, page.Total); + Assert.Single(page.Items); + Assert.Equal("Hello world", page.Items[0].Content); + + // Get all (including pending) + McpQuotePage allPage = await service.GetQuotesAsync(approvedOnly: false); + + Assert.Equal(2, allPage.Total); + } + + [Fact] + public async Task GetQuoteByIdAsync_ReturnsQuote() + { + await using SqliteTestDb testDb = await CreateSqliteDbAsync(); + (Guild guild, User user) = await SeedBaseAsync(testDb.Db); + + Quote quote = new() + { + GuildId = guild.Id, + UserId = user.Id, + Content = "Test quote detail", + Approved = true + }; + testDb.Db.Quotes.Add(quote); + await testDb.Db.SaveChangesAsync(); + + McpService service = CreateService(testDb.Db); + + McpQuoteDetail? detail = await service.GetQuoteByIdAsync(quote.Id); + + Assert.NotNull(detail); + Assert.Equal(quote.Id, detail.Id); + Assert.Equal("Test quote detail", detail.Content); + Assert.Equal(user.Username, detail.Author); + } + + [Fact] + public async Task GetQuoteByIdAsync_ReturnsNull_WhenNotFound() + { + await using SqliteTestDb testDb = await CreateSqliteDbAsync(); + McpService service = CreateService(testDb.Db); + + McpQuoteDetail? detail = await service.GetQuoteByIdAsync(999); + + Assert.Null(detail); + } + + [Fact] + public async Task GetRecentLogsAsync_ReturnsLogs() + { + await using SqliteTestDb testDb = await CreateSqliteDbAsync(); + + testDb.Db.Logs.Add(new Log { Message = "info log", Severity = (int)Discord.LogSeverity.Info, InsertDate = DateTime.UtcNow }); + testDb.Db.Logs.Add(new Log { Message = "warning log", Severity = (int)Discord.LogSeverity.Warning, InsertDate = DateTime.UtcNow }); + testDb.Db.Logs.Add(new Log { Message = "error log", Severity = (int)Discord.LogSeverity.Error, InsertDate = DateTime.UtcNow }); + await testDb.Db.SaveChangesAsync(); + + McpService service = CreateService(testDb.Db); + + IReadOnlyList logs = await service.GetRecentLogsAsync(limit: 10); + + Assert.Equal(3, logs.Count); + + // Filter by severity + IReadOnlyList errorLogs = await service.GetRecentLogsAsync(limit: 10, severity: "Error"); + Assert.Single(errorLogs); + Assert.Equal("error log", errorLogs[0].Message); + } + + [Fact] + public async Task GetLeaderboardAsync_ReturnsRankings() + { + await using SqliteTestDb testDb = await CreateSqliteDbAsync(); + (Guild guild, User user) = await SeedBaseAsync(testDb.Db); + + testDb.Db.UserActivity.Add(new UserActivity + { + GuildId = guild.Id, + UserId = user.Id, + DiscordChannelId = 1, + DiscordMessageId = 2, + XpGained = 100, + MessageLength = 25, + InsertDate = DateTime.UtcNow.AddHours(-1) + }); + await testDb.Db.SaveChangesAsync(); + + McpService service = CreateService(testDb.Db); + + IReadOnlyList leaderboard = await service.GetLeaderboardAsync( + metric: "xp", guildId: guild.Id, days: 30, limit: 10); + + Assert.Single(leaderboard); + Assert.Equal(1, leaderboard[0].Rank); + Assert.Equal(user.Id, leaderboard[0].UserId); + Assert.Equal(100, leaderboard[0].Value); + } + + [Fact] + public async Task GetStockSummaryAsync_ReturnsSummary() + { + await using SqliteTestDb testDb = await CreateSqliteDbAsync(); + (Guild _, User user) = await SeedBaseAsync(testDb.Db); + + testDb.Db.Stocks.Add(new Stock + { + EntityType = Database.Enums.StockEntityType.User, + EntityId = user.Id, + Price = 120m, + PreviousPrice = 100m, + DailyChangePercent = 20m + }); + testDb.Db.Stocks.Add(new Stock + { + EntityType = Database.Enums.StockEntityType.Guild, + EntityId = 2, + Price = 80m, + PreviousPrice = 100m, + DailyChangePercent = -20m + }); + testDb.Db.Stocks.Add(new Stock + { + EntityType = Database.Enums.StockEntityType.Guild, + EntityId = 3, + Price = 50m, + PreviousPrice = 100m, + DailyChangePercent = -50m + }); + await testDb.Db.SaveChangesAsync(); + + McpService service = CreateService(testDb.Db); + + McpStockSummary summary = await service.GetStockSummaryAsync(moverLimit: 5); + + Assert.Equal(3, summary.TotalStocks); + Assert.Single(summary.TopGainers); + Assert.Equal(2, summary.TopLosers.Count); + Assert.Equal(20m, summary.TopGainers[0].DailyChangePercent); + Assert.Equal(-50m, summary.TopLosers[0].DailyChangePercent); + } + + // ── Test Infrastructure ── + + private sealed class SqliteTestDb(SqliteConnection connection, DB db) : IAsyncDisposable + { + public DB Db { get; } = db; + + public async ValueTask DisposeAsync() + { + await Db.DisposeAsync(); + await connection.DisposeAsync(); + } + } + + private static async Task CreateSqliteDbAsync() + { + SqliteConnection connection = new("DataSource=:memory:"); + await connection.OpenAsync(); + + DbContextOptions options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + DB db = new(options); + await db.Database.EnsureCreatedAsync(); + + return new SqliteTestDb(connection, db); + } + + private static async Task<(Guild Guild, User User)> SeedBaseAsync(DB db) + { + Guild guild = new() + { + DiscordId = 123, + Name = "Test Server", + Prefix = "m!" + }; + db.Guilds.Add(guild); + await db.SaveChangesAsync(); + + User user = new() + { + DiscordId = 456, + Username = "TestUser", + Balance = 1000m + }; + db.Users.Add(user); + await db.SaveChangesAsync(); + + return (guild, user); + } + + private static McpService CreateService(DB db) => new(db); +} \ No newline at end of file diff --git a/Program.cs b/Program.cs index 7227179..f759b3a 100644 --- a/Program.cs +++ b/Program.cs @@ -1,20 +1,28 @@ -using Microsoft.Extensions.Hosting; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; using Morpheus.Extensions; +using Morpheus.MCP; using Morpheus.Utilities; Env.Load(".env"); -IHost host = Host.CreateDefaultBuilder(args) - .ConfigureServices((_, services) => - { - services - .AddBotServices() - .AddBotJobs() - .AddBotHandlers() - .AddBotDatabase(); - }) - .Build(); +McpApiOptions mcpOptions = McpApiOptions.FromEnvironment(); -host.RunStartupMigrations(); -await host.StartBotAsync(); -await host.RunAsync(); +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); +builder.WebHost.UseUrls(mcpOptions.Urls); + +builder.Services + .AddBotServices() + .AddBotJobs() + .AddBotHandlers() + .AddBotDatabase() + .AddMcpApi(mcpOptions); + +WebApplication app = builder.Build(); + +app.UseCors(); +app.MapMcpApi(); + +app.RunStartupMigrations(); +await app.StartBotAsync(); +await app.RunAsync(); From a942f8414b477566b6989a1cf612891f929f71c5 Mon Sep 17 00:00:00 2001 From: vycdev2 Date: Tue, 4 Aug 2026 02:25:30 +0000 Subject: [PATCH 2/3] fix: secure MCP protocol endpoint --- CHANGELOG.md | 7 + MCP/McpApiExtensions.cs | 345 +++------------- MCP/McpApiOptions.cs | 82 +++- MCP/McpContracts.cs | 176 +------- MCP/McpSecurityMiddleware.cs | 57 +++ MCP/McpService.cs | 555 ++++++-------------------- MCP/McpTools.cs | 83 ++++ Morpheus.Tests/McpApiEndpointTests.cs | 354 ++++++++++++++++ Morpheus.Tests/McpServiceTests.cs | 398 ++++-------------- Morpheus.Tests/Morpheus.Tests.csproj | 1 + Morpheus.csproj | 1 + Program.cs | 10 +- README.md | 23 ++ default.env | 8 + 14 files changed, 876 insertions(+), 1224 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 MCP/McpSecurityMiddleware.cs create mode 100644 MCP/McpTools.cs create mode 100644 Morpheus.Tests/McpApiEndpointTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5bc5a70 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +## Unreleased + +- Add an authenticated, origin-restricted, rate-limited MCP Streamable HTTP + server for read-only aggregate activity, guild statistics, leaderboards, and + approved quotes. diff --git a/MCP/McpApiExtensions.cs b/MCP/McpApiExtensions.cs index adc2e07..dd3617d 100644 --- a/MCP/McpApiExtensions.cs +++ b/MCP/McpApiExtensions.cs @@ -1,331 +1,94 @@ +using System.Threading.RateLimiting; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.DependencyInjection; -using System.Text.Json; namespace Morpheus.MCP; -/// -/// Extension methods for registering and mapping the MCP API endpoints. -/// Follows the same pattern as the Dashboard API. -/// public static class McpApiExtensions { private const string CorsPolicyName = "McpCors"; + private const string RateLimitPolicyName = "McpRateLimit"; - private static readonly IReadOnlyList ToolDefinitions = - [ - new McpToolDefinition( - "get_user_stats", - "Get detailed statistics for a user including balance, XP, messages, level, quotes, and activity.", - [ - new McpToolParameter("userId", "integer", "Internal user ID (optional if discordId is provided)", false), - new McpToolParameter("discordId", "string", "Discord user ID (optional if userId is provided)", false), - ]), - new McpToolDefinition( - "get_guild_info", - "Get information about a Discord server/guild including settings, activity stats, and quote count.", - [ - new McpToolParameter("guildId", "integer", "Internal guild ID (optional if discordId is provided)", false), - new McpToolParameter("discordId", "string", "Discord guild ID (optional if guildId is provided)", false), - ]), - new McpToolDefinition( - "get_economy_summary", - "Get overall economy summary including total balances, UBI pool size, slots vault, and stock market info.", - []), - new McpToolDefinition( - "get_activity_overview", - "Get global activity overview including total messages, XP, active users, and server counts.", - []), - new McpToolDefinition( - "get_guilds", - "List all Discord servers the bot is connected to with their activity stats.", - []), - new McpToolDefinition( - "get_users", - "Get a paginated list of all known users.", - [ - new McpToolParameter("page", "integer", "Page number (default: 1)", false, 1), - new McpToolParameter("limit", "integer", "Results per page (default: 20, max: 100)", false, 20), - ]), - new McpToolDefinition( - "get_quotes", - "Get a paginated list of quotes with optional filtering by guild, sort order, and approval status.", - [ - new McpToolParameter("page", "integer", "Page number (default: 1)", false, 1), - new McpToolParameter("sort", "string", "Sort order: newest, oldest, or score (default: newest)", false, "newest"), - new McpToolParameter("approvedOnly", "boolean", "Only show approved quotes (default: true)", false, true), - new McpToolParameter("guildId", "integer", "Filter by guild ID (optional)", false), - ]), - new McpToolDefinition( - "get_quote_by_id", - "Get detailed information about a specific quote by its ID.", - [ - new McpToolParameter("quoteId", "integer", "Quote ID", true), - ]), - new McpToolDefinition( - "get_recent_logs", - "Get recent bot log entries, optionally filtered by severity level.", - [ - new McpToolParameter("limit", "integer", "Number of entries (default: 20, max: 100)", false, 20), - new McpToolParameter("severity", "string", "Filter by severity: Info, Warning, Error, Verbose, Debug (optional)", false), - ]), - new McpToolDefinition( - "get_stock_summary", - "Get stock market summary including total stocks, top gainers, and top losers.", - [ - new McpToolParameter("limit", "integer", "Number of gainers/losers to return (default: 5)", false, 5), - ]), - new McpToolDefinition( - "get_leaderboard", - "Get activity leaderboard by XP or messages, optionally filtered by guild and time period.", - [ - new McpToolParameter("metric", "string", "Metric: xp or messages (default: xp)", false, "xp"), - new McpToolParameter("guildId", "integer", "Filter by guild ID (optional)", false), - new McpToolParameter("days", "integer", "Lookback period in days (default: 30)", false, 30), - new McpToolParameter("limit", "integer", "Number of entries (default: 10, max: 50)", false, 10), - ]), - ]; - - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = false, - }; - - /// - /// Registers MCP API services in the DI container. - /// public static IServiceCollection AddMcpApi( this IServiceCollection services, McpApiOptions options) { + options.Validate(); services.AddSingleton(options); + + if (!options.Enabled) + return services; + services.AddScoped(); - services.ConfigureHttpJsonOptions(jsonOptions => - { - jsonOptions.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; - }); + services + .AddMcpServer() + .WithHttpTransport(transport => transport.Stateless = true) + .WithTools(); services.AddCors(corsOptions => { corsOptions.AddPolicy(CorsPolicyName, policy => { - policy - .AllowAnyOrigin() - .AllowAnyHeader() - .AllowAnyMethod(); - }); - }); - - return services; - } - - /// - /// Maps MCP API endpoints to the application. - /// - public static WebApplication MapMcpApi(this WebApplication app) - { - app.MapGet("/api/mcp", () => Results.Ok(new McpServerInfo( - "Morpheus MCP Server", - "1.0.0", - "/api/mcp/health", - "/api/mcp/tools", - "/api/mcp/call/{toolName}", - [.. ToolDefinitions.Select(t => t.Name)] - ))); - - app.MapGet("/api/mcp/health", (McpApiOptions options) => Results.Ok(new - { - status = "ok", - service = "Morpheus MCP Server", - version = "1.0.0", - startedAtUtc = Utilities.Env.StartTime, - authEnabled = !string.IsNullOrWhiteSpace(options.ApiKey) - })); - - RouteGroupBuilder api = app - .MapGroup("/api/mcp") - .RequireCors(CorsPolicyName); - - // List available tools (MCP protocol discovery) - api.MapGet("/tools", () => Results.Ok(new - { - tools = ToolDefinitions - })); - - // Call a specific tool - api.MapPost("/call/{toolName}", async ( - string toolName, - McpToolCallRequest? request, - McpService mcpService, - CancellationToken cancellationToken) => - { - Dictionary parameters = request?.Params ?? []; - - try - { - object? result = toolName.ToLowerInvariant() switch - { - "get_user_stats" => await mcpService.GetUserStatsAsync( - GetIntParam(parameters, "userId"), - GetULongParam(parameters, "discordId"), - cancellationToken), - - "get_guild_info" => await mcpService.GetGuildInfoAsync( - GetIntParam(parameters, "guildId"), - GetULongParam(parameters, "discordId"), - cancellationToken), - - "get_economy_summary" => await mcpService.GetEconomySummaryAsync(cancellationToken), - - "get_activity_overview" => await mcpService.GetActivityOverviewAsync(cancellationToken), - - "get_guilds" => await mcpService.GetGuildsAsync(cancellationToken), - - "get_users" => await mcpService.GetUsersAsync( - GetIntParam(parameters, "page") ?? 1, - GetIntParam(parameters, "limit") ?? 20, - cancellationToken), - - "get_quotes" => await mcpService.GetQuotesAsync( - GetIntParam(parameters, "page") ?? 1, - GetStringParam(parameters, "sort") ?? "newest", - GetBoolParam(parameters, "approvedOnly") ?? true, - GetIntParam(parameters, "guildId"), - cancellationToken), - - "get_quote_by_id" => await mcpService.GetQuoteByIdAsync( - GetIntParam(parameters, "quoteId") ?? throw new ArgumentException("quoteId is required"), - cancellationToken), - - "get_recent_logs" => await mcpService.GetRecentLogsAsync( - GetIntParam(parameters, "limit") ?? 20, - GetStringParam(parameters, "severity"), - cancellationToken), - - "get_stock_summary" => await mcpService.GetStockSummaryAsync( - GetIntParam(parameters, "limit") ?? 5, - cancellationToken), - - "get_leaderboard" => await mcpService.GetLeaderboardAsync( - GetStringParam(parameters, "metric") ?? "xp", - GetIntParam(parameters, "guildId"), - GetIntParam(parameters, "days") ?? 30, - GetIntParam(parameters, "limit") ?? 10, - cancellationToken), - - _ => null - }; - - if (result is null && toolDefinitionsLut.TryGetValue(toolName.ToLowerInvariant(), out _)) + if (options.AllowedOrigins.Length > 0) { - // Known tool but returned null (e.g. entity not found) - return Results.Ok(new McpToolResponse(false, null, "Not found or no parameters provided.")); + policy + .WithOrigins(options.AllowedOrigins) + .WithMethods("GET", "POST", "DELETE") + .WithHeaders( + "Authorization", + "Content-Type", + "MCP-Protocol-Version", + "Mcp-Method", + "Mcp-Name", + "Mcp-Session-Id") + .WithExposedHeaders("WWW-Authenticate", "Mcp-Session-Id"); } - - if (result is null) - { - return Results.NotFound(new McpToolResponse(false, null, $"Unknown tool: {toolName}")); - } - - return Results.Ok(new McpToolResponse(true, result)); - } - catch (ArgumentException ex) - { - return Results.BadRequest(new McpToolResponse(false, null, ex.Message)); - } - catch (Exception ex) - { - return Results.Ok(new McpToolResponse(false, null, $"Internal error: {ex.Message}")); - } + }); }); - return app; - } - - private static readonly Dictionary toolDefinitionsLut = ToolDefinitions - .ToDictionary(t => t.Name, _ => true); - - // ── Parameter Helpers ── - - private static int? GetIntParam(Dictionary parameters, string key) - { - if (!parameters.TryGetValue(key, out object? value) || value is null) - return null; - - if (value is JsonElement je) + services.AddRateLimiter(rateLimitOptions => { - if (je.ValueKind == JsonValueKind.Number && je.TryGetInt32(out int intVal)) - return intVal; - if (je.ValueKind == JsonValueKind.String && int.TryParse(je.GetString(), out int parsed)) - return parsed; - return null; - } - - if (value is int i) return i; - if (value is long l) return (int)l; - if (value is string s && int.TryParse(s, out int parsedStr)) return parsedStr; - return null; - } - - private static ulong? GetULongParam(Dictionary parameters, string key) - { - if (!parameters.TryGetValue(key, out object? value) || value is null) - return null; - - if (value is JsonElement je) - { - if (je.ValueKind == JsonValueKind.Number && je.TryGetUInt64(out ulong ulVal)) - return ulVal; - if (je.ValueKind == JsonValueKind.String && ulong.TryParse(je.GetString(), out ulong parsed)) - return parsed; - return null; - } + rateLimitOptions.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + rateLimitOptions.AddPolicy(RateLimitPolicyName, context => + RateLimitPartition.GetFixedWindowLimiter( + context.Connection.RemoteIpAddress?.ToString() ?? "unknown", + _ => new FixedWindowRateLimiterOptions + { + PermitLimit = options.RequestsPerMinute, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0, + AutoReplenishment = true + })); + }); - if (value is ulong ul) return ul; - if (value is string s && ulong.TryParse(s, out ulong parsedStr)) return parsedStr; - return null; + return services; } - private static string? GetStringParam(Dictionary parameters, string key) + public static WebApplication UseMcpApiSecurity(this WebApplication app) { - if (!parameters.TryGetValue(key, out object? value) || value is null) - return null; - - if (value is JsonElement je) + McpApiOptions options = app.Services.GetRequiredService(); + if (options.Enabled) { - return je.ValueKind switch - { - JsonValueKind.String => je.GetString(), - JsonValueKind.Number => je.GetRawText(), - JsonValueKind.True => "true", - JsonValueKind.False => "false", - _ => null - }; + app.UseRateLimiter(); + app.UseMiddleware(); } - return value?.ToString(); + return app; } - private static bool? GetBoolParam(Dictionary parameters, string key) + public static WebApplication MapMcpApi(this WebApplication app) { - if (!parameters.TryGetValue(key, out object? value) || value is null) - return null; + McpApiOptions options = app.Services.GetRequiredService(); + if (!options.Enabled) + return app; - if (value is JsonElement je) - { - if (je.ValueKind == JsonValueKind.True) return true; - if (je.ValueKind == JsonValueKind.False) return false; - if (je.ValueKind == JsonValueKind.String && bool.TryParse(je.GetString(), out bool parsed)) - return parsed; - return null; - } + app.MapMcp("/api/mcp") + .RequireCors(CorsPolicyName) + .RequireRateLimiting(RateLimitPolicyName); - if (value is bool b) return b; - if (value is string s && bool.TryParse(s, out bool parsedStr)) return parsedStr; - return null; + return app; } -} \ No newline at end of file +} diff --git a/MCP/McpApiOptions.cs b/MCP/McpApiOptions.cs index b77354c..9608846 100644 --- a/MCP/McpApiOptions.cs +++ b/MCP/McpApiOptions.cs @@ -3,16 +3,84 @@ namespace Morpheus.MCP; /// -/// Configuration options for the MCP API server. +/// Security and rate-limit configuration for the MCP endpoint. +/// The MCP server is disabled unless an API key is configured. /// public sealed record McpApiOptions( - string Urls, - string ApiKey) + string[] AllowedOrigins, + string ApiKey, + int RequestsPerMinute) { + public const int DefaultRequestsPerMinute = 60; + public const string DefaultListenerUrls = "http://127.0.0.1:5268"; + + public string ListenerUrls { get; init; } = DefaultListenerUrls; + + public bool Enabled => !string.IsNullOrWhiteSpace(ApiKey); + public static McpApiOptions FromEnvironment() { - string urls = Env.Get("MCP_API_URLS", "http://127.0.0.1:5268"); - string apiKey = Env.Get("MCP_API_KEY", string.Empty); - return new McpApiOptions(urls, apiKey); + string configuredOrigins = Env.Get( + "MCP_ALLOWED_ORIGINS", + "http://localhost:3000,http://127.0.0.1:3000"); + + string[] origins = [.. configuredOrigins + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(NormalizeOrigin) + .Distinct(StringComparer.OrdinalIgnoreCase)]; + + return new McpApiOptions( + origins, + Env.Get("MCP_API_KEY", string.Empty), + Env.Get("MCP_RATE_LIMIT_PER_MINUTE", DefaultRequestsPerMinute)) + { + ListenerUrls = Env.Get("MCP_API_URLS", DefaultListenerUrls) + }; + } + + public void Validate() + { + if (!Enabled) + return; + + if (RequestsPerMinute <= 0) + throw new InvalidOperationException("MCP_RATE_LIMIT_PER_MINUTE must be greater than zero."); + + foreach (string origin in AllowedOrigins) + _ = NormalizeOrigin(origin); + } + + public bool IsAllowedOrigin(string origin) + { + string normalized; + try + { + normalized = NormalizeOrigin(origin); + } + catch (InvalidOperationException) + { + return false; + } + + return AllowedOrigins.Any(allowed => string.Equals( + NormalizeOrigin(allowed), + normalized, + StringComparison.OrdinalIgnoreCase)); + } + + public static string NormalizeOrigin(string value) + { + if (!Uri.TryCreate(value, UriKind.Absolute, out Uri? uri) || + (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) || + !string.IsNullOrEmpty(uri.UserInfo) || + (uri.AbsolutePath != "/" && !string.IsNullOrEmpty(uri.AbsolutePath)) || + !string.IsNullOrEmpty(uri.Query) || + !string.IsNullOrEmpty(uri.Fragment)) + { + throw new InvalidOperationException( + $"Invalid MCP origin '{value}'. Origins must contain only an http(s) scheme, host, and optional port."); + } + + return uri.GetLeftPart(UriPartial.Authority).TrimEnd('/'); } -} \ No newline at end of file +} diff --git a/MCP/McpContracts.cs b/MCP/McpContracts.cs index 534f7cf..f3e1dcd 100644 --- a/MCP/McpContracts.cs +++ b/MCP/McpContracts.cs @@ -1,101 +1,13 @@ -using System.Text.Json.Serialization; - namespace Morpheus.MCP; -/// -/// MCP tool definition returned by the /tools endpoint. -/// Describes a callable tool, its parameters, and purpose. -/// -public sealed record McpToolDefinition( - string Name, - string Description, - IReadOnlyList Parameters); - -/// -/// Describes a single parameter for an MCP tool. -/// -public sealed record McpToolParameter( - string Name, - string Type, - string Description, - bool Required = false, - object? Default = null); - -/// -/// MCP server info response. -/// -public sealed record McpServerInfo( - string Service, - string Version, - string Health, - string Tools, - string Call, - string[] AvailableTools); - -/// -/// Request body for calling an MCP tool. -/// -public sealed record McpToolCallRequest( - [property: JsonPropertyName("params")] - Dictionary? Params); - -/// -/// Successful MCP tool response. -/// -public sealed record McpToolResponse( - bool Success, - object? Data, - string? Error = null); - -/// -/// User stats response. -/// -public sealed record McpUserStats( - int Id, - ulong DiscordId, - string Username, - DateTime CreatedAtUtc, - decimal Balance, - int TotalMessages, - long TotalXp, - int? Level, - int QuoteCount, - int QuoteScore, - int ButtonScore, - DateTime? LastActivityAtUtc); - -/// -/// Guild info response. -/// public sealed record McpGuildInfo( int Id, - ulong DiscordId, string Name, - DateTime CreatedAtUtc, - string Prefix, int TrackedUsers, long Messages, long Xp, - int ApprovedQuotes, - bool UseGlobalQuotes, - bool WelcomeMessages, - bool UseActivityRoles); - -/// -/// Economy summary response. -/// -public sealed record McpEconomySummary( - int TotalUsers, - decimal TotalBalance, - decimal AverageBalance, - decimal UbiPoolSize, - decimal SlotsVaultSize, - int TotalStocks, - decimal TotalStockPortfolioValue); + int ApprovedQuotes); -/// -/// Activity overview response. -/// public sealed record McpActivityOverview( long TotalMessages, long TotalXp, @@ -103,106 +15,32 @@ public sealed record McpActivityOverview( long MessagesLast30Days, long XpLast30Days, int TotalServers, - int TotalKnownUsers, - DateTime? LastActivityAtUtc); - -/// -/// Server list item. -/// -public sealed record McpServerItem( - int Id, - ulong DiscordId, - string Name, - DateTime CreatedAtUtc, - int TrackedUsers, - long Messages, - long Xp, - int ApprovedQuotes); + int TotalKnownUsers); -/// -/// User list item. -/// -public sealed record McpUserItem( - int Id, - ulong DiscordId, - string Username, - DateTime CreatedAtUtc, - decimal Balance, - long Messages, - long Xp, - int? Level); - -/// -/// Leaderboard entry. -/// -public sealed record McpLeaderboardEntry( - int Rank, - int UserId, - ulong DiscordId, - string Username, - long Value, - int? Level); - -/// -/// Page of quotes. -/// public sealed record McpQuotePage( int Page, int TotalPages, int Total, IReadOnlyList Items); -/// -/// Single quote item. -/// public sealed record McpQuoteItem( int Id, int GuildId, - int UserId, string Author, string Content, DateTime InsertedAtUtc, - bool Approved, - bool Removed, int Score); -/// -/// Quote detail. -/// public sealed record McpQuoteDetail( int Id, int GuildId, string Content, DateTime InsertedAtUtc, - bool Approved, - bool Removed, int TotalScore, string Author); -/// -/// Moderation log entry. -/// -public sealed record McpModerationEntry( - long Id, - string Severity, - string Message, - DateTime InsertedAtUtc); - -/// -/// Stock market summary. -/// -public sealed record McpStockSummary( - int TotalStocks, - IReadOnlyList TopGainers, - IReadOnlyList TopLosers); - -/// -/// Stock market item. -/// -public sealed record McpStockItem( - int StockId, - string EntityType, - int EntityId, - string Name, - decimal Price, - decimal DailyChangePercent); \ No newline at end of file +public sealed record McpLeaderboardEntry( + int Rank, + string Username, + long Value, + int? Level); diff --git a/MCP/McpSecurityMiddleware.cs b/MCP/McpSecurityMiddleware.cs new file mode 100644 index 0000000..6dc4e79 --- /dev/null +++ b/MCP/McpSecurityMiddleware.cs @@ -0,0 +1,57 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; + +namespace Morpheus.MCP; + +/// +/// Enforces exact Origin validation and bearer-key authorization for every MCP request. +/// +public sealed class McpSecurityMiddleware( + RequestDelegate next, + McpApiOptions options) +{ + public async Task InvokeAsync(HttpContext context) + { + if (!context.Request.Path.StartsWithSegments("/api/mcp")) + { + await next(context); + return; + } + + if (context.Request.Headers.TryGetValue("Origin", out StringValues origins) && + (origins.Count != 1 || !options.IsAllowedOrigin(origins[0]!))) + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + await context.Response.WriteAsJsonAsync(new + { + error = "The request origin is not allowed." + }); + return; + } + + string authorization = context.Request.Headers.Authorization.ToString(); + const string bearerPrefix = "Bearer "; + if (!authorization.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase) || + !KeysMatch(authorization[bearerPrefix.Length..].Trim(), options.ApiKey)) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + context.Response.Headers.WWWAuthenticate = "Bearer"; + await context.Response.WriteAsJsonAsync(new + { + error = "A valid MCP bearer token is required." + }); + return; + } + + await next(context); + } + + private static bool KeysMatch(string supplied, string expected) + { + byte[] suppliedHash = SHA256.HashData(Encoding.UTF8.GetBytes(supplied)); + byte[] expectedHash = SHA256.HashData(Encoding.UTF8.GetBytes(expected)); + return CryptographicOperations.FixedTimeEquals(suppliedHash, expectedHash); + } +} diff --git a/MCP/McpService.cs b/MCP/McpService.cs index c8fb89a..780f18e 100644 --- a/MCP/McpService.cs +++ b/MCP/McpService.cs @@ -1,169 +1,61 @@ using Microsoft.EntityFrameworkCore; using Morpheus.Database; -using Morpheus.Database.Enums; using Morpheus.Database.Models; namespace Morpheus.MCP; /// -/// Service that provides data for the MCP API endpoints. -/// Wraps database queries to expose bot data to AI agents. +/// Read-only, deliberately limited data surface exposed through MCP. /// public sealed class McpService(DB dbContext) { - private const string UbiPoolSettingKey = "ubi_pool"; - private const string SlotsVaultSettingKey = "slots_vault"; - private const decimal SlotsVaultDefaultAmount = 10000.00m; - - /// - /// Gets stats for a specific user by id or discord id. - /// - public async Task GetUserStatsAsync(int? userId, ulong? discordId, CancellationToken ct = default) - { - IQueryable query = dbContext.Users.AsNoTracking(); - - if (userId.HasValue) - query = query.Where(u => u.Id == userId.Value); - else if (discordId.HasValue) - query = query.Where(u => u.DiscordId == discordId.Value); - else - return null; + private const int QuotePageSize = 10; - User? user = await query.FirstOrDefaultAsync(ct); - if (user == null) return null; - - var levels = await dbContext.UserLevels - .AsNoTracking() - .Where(ul => ul.UserId == user.Id) - .GroupBy(_ => 1) - .Select(g => new - { - Messages = g.Sum(ul => (long)ul.UserMessageCount), - Xp = g.Sum(ul => (long)ul.TotalXp), - MaxLevel = g.Max(ul => (int?)ul.Level) - }) - .FirstOrDefaultAsync(ct); - - int quoteCount = await dbContext.Quotes - .AsNoTracking() - .CountAsync(q => q.UserId == user.Id && !q.Removed, ct); - - int quoteScore = await dbContext.QuoteScores - .AsNoTracking() - .Where(qs => qs.UserId == user.Id) - .SumAsync(qs => (int?)qs.Score, ct) ?? 0; - - int buttonScore = await dbContext.ButtonGamePresses - .AsNoTracking() - .CountAsync(bp => bp.UserId == user.Id, ct); - - DateTime? lastActivity = await dbContext.UserActivity - .AsNoTracking() - .Where(ua => ua.UserId == user.Id) - .Select(ua => (DateTime?)ua.InsertDate) - .MaxAsync(ct); - - return new McpUserStats( - user.Id, - user.DiscordId, - user.Username, - user.InsertDate, - user.Balance, - (int)(levels?.Messages ?? 0), - levels?.Xp ?? 0, - levels?.MaxLevel, - quoteCount, - quoteScore, - buttonScore, - lastActivity); - } - - /// - /// Gets guild info by id or discord id. - /// - public async Task GetGuildInfoAsync(int? guildId, ulong? discordId, CancellationToken ct = default) + public async Task GetGuildInfoAsync( + int? guildId, + ulong? discordId, + CancellationToken ct = default) { IQueryable query = dbContext.Guilds.AsNoTracking(); - if (guildId.HasValue) + if (guildId is > 0) query = query.Where(g => g.Id == guildId.Value); - else if (discordId.HasValue) + else if (discordId is > 0) query = query.Where(g => g.DiscordId == discordId.Value); else - return null; + throw new ArgumentException("Provide a positive guildId or discordId."); Guild? guild = await query.FirstOrDefaultAsync(ct); - if (guild == null) return null; + if (guild is null) + return null; var levels = await dbContext.UserLevels .AsNoTracking() - .Where(ul => ul.GuildId == guild.Id) + .Where(level => level.GuildId == guild.Id) .GroupBy(_ => 1) - .Select(g => new + .Select(group => new { - Messages = g.Sum(ul => (long)ul.UserMessageCount), - Xp = g.Sum(ul => (long)ul.TotalXp), - Users = g.Count() + Messages = group.Sum(level => (long)level.UserMessageCount), + Xp = group.Sum(level => (long)level.TotalXp), + Users = group.Count() }) .FirstOrDefaultAsync(ct); int approvedQuotes = await dbContext.Quotes .AsNoTracking() - .CountAsync(q => q.GuildId == guild.Id && q.Approved && !q.Removed, ct); + .CountAsync(quote => + quote.GuildId == guild.Id && quote.Approved && !quote.Removed, + ct); return new McpGuildInfo( guild.Id, - guild.DiscordId, guild.Name, - guild.InsertDate, - guild.Prefix, levels?.Users ?? 0, levels?.Messages ?? 0, levels?.Xp ?? 0, - approvedQuotes, - guild.UseGlobalQuotes, - guild.WelcomeMessages, - guild.UseActivityRoles); + approvedQuotes); } - /// - /// Gets economy summary: total balances, pool, vault, stocks. - /// - public async Task GetEconomySummaryAsync(CancellationToken ct = default) - { - int totalUsers = await dbContext.Users.AsNoTracking().CountAsync(ct); - decimal totalBalance = (await dbContext.Users - .AsNoTracking() - .Select(u => u.Balance) - .ToListAsync(ct)) - .Sum(); - - decimal averageBalance = totalUsers > 0 ? totalBalance / totalUsers : 0m; - - decimal ubiPool = await GetBotSettingDecimalAsync(UbiPoolSettingKey, 0m, ct); - decimal slotsVault = await GetBotSettingDecimalAsync(SlotsVaultSettingKey, SlotsVaultDefaultAmount, ct); - - int totalStocks = await dbContext.Stocks.AsNoTracking().CountAsync(ct); - decimal totalPortfolio = (await dbContext.StockHoldings - .AsNoTracking() - .Include(sh => sh.Stock) - .Select(sh => sh.Shares * sh.Stock!.Price) - .ToListAsync(ct)) - .Sum(); - - return new McpEconomySummary( - totalUsers, - totalBalance, - Math.Round(averageBalance, 2), - ubiPool, - slotsVault, - totalStocks, - Math.Round(totalPortfolio, 2)); - } - - /// - /// Gets a global activity overview. - /// public async Task GetActivityOverviewAsync(CancellationToken ct = default) { DateTime last30Days = DateTime.UtcNow.AddDays(-30); @@ -171,225 +63,116 @@ public async Task GetActivityOverviewAsync(CancellationToke var levelTotals = await dbContext.UserLevels .AsNoTracking() .GroupBy(_ => 1) - .Select(g => new + .Select(group => new { - Messages = g.Sum(ul => (long)ul.UserMessageCount), - Xp = g.Sum(ul => (long)ul.TotalXp) + Messages = group.Sum(level => (long)level.UserMessageCount), + Xp = group.Sum(level => (long)level.TotalXp) }) .FirstOrDefaultAsync(ct); - long totalMessages = levelTotals?.Messages ?? 0L; - long totalXp = levelTotals?.Xp ?? 0L; - IQueryable recentActivity = dbContext.UserActivity .AsNoTracking() - .Where(ua => ua.InsertDate >= last30Days); - - int activeUsers = await recentActivity - .Select(ua => ua.UserId) - .Distinct() - .CountAsync(ct); - - long messagesLast30Days = await recentActivity.LongCountAsync(ct); - long xpLast30Days = await recentActivity - .SumAsync(ua => (long?)ua.XpGained, ct) ?? 0L; - - int totalServers = await dbContext.Guilds.AsNoTracking().CountAsync(ct); - int totalUsers = await dbContext.Users.AsNoTracking().CountAsync(ct); - - DateTime? lastActivity = await dbContext.UserActivity - .AsNoTracking() - .Select(ua => (DateTime?)ua.InsertDate) - .MaxAsync(ct); + .Where(activity => activity.InsertDate >= last30Days); return new McpActivityOverview( - totalMessages, - totalXp, - activeUsers, - messagesLast30Days, - xpLast30Days, - totalServers, - totalUsers, - lastActivity); - } - - /// - /// Gets a list of all servers/guilds. - /// - public async Task> GetGuildsAsync(CancellationToken ct = default) - { - var guilds = await dbContext.Guilds - .AsNoTracking() - .OrderByDescending(g => g.InsertDate) - .ToListAsync(ct); - - var result = new List(guilds.Count); - foreach (Guild guild in guilds) - { - var levels = await dbContext.UserLevels - .AsNoTracking() - .Where(ul => ul.GuildId == guild.Id) - .GroupBy(_ => 1) - .Select(g => new - { - Messages = g.Sum(ul => (long)ul.UserMessageCount), - Xp = g.Sum(ul => (long)ul.TotalXp), - Users = g.Count() - }) - .FirstOrDefaultAsync(ct); - - int approvedQuotes = await dbContext.Quotes - .AsNoTracking() - .CountAsync(q => q.GuildId == guild.Id && q.Approved && !q.Removed, ct); - - result.Add(new McpServerItem( - guild.Id, - guild.DiscordId, - guild.Name, - guild.InsertDate, - levels?.Users ?? 0, - levels?.Messages ?? 0, - levels?.Xp ?? 0, - approvedQuotes)); - } - - return result.AsReadOnly(); + levelTotals?.Messages ?? 0, + levelTotals?.Xp ?? 0, + await recentActivity.Select(activity => activity.UserId).Distinct().CountAsync(ct), + await recentActivity.LongCountAsync(ct), + await recentActivity.SumAsync(activity => (long?)activity.XpGained, ct) ?? 0, + await dbContext.Guilds.AsNoTracking().CountAsync(ct), + await dbContext.Users.AsNoTracking().CountAsync(ct)); } - /// - /// Gets a paginated list of users. - /// - public async Task> GetUsersAsync(int page = 1, int limit = 20, CancellationToken ct = default) - { - if (page < 1) page = 1; - if (limit < 1) limit = 20; - if (limit > 100) limit = 100; - - var users = await dbContext.Users - .AsNoTracking() - .OrderByDescending(u => u.InsertDate) - .Skip((page - 1) * limit) - .Take(limit) - .ToListAsync(ct); - - var result = new List(users.Count); - foreach (User user in users) - { - var levels = await dbContext.UserLevels - .AsNoTracking() - .Where(ul => ul.UserId == user.Id) - .GroupBy(_ => 1) - .Select(g => new - { - Messages = g.Sum(ul => (long)ul.UserMessageCount), - Xp = g.Sum(ul => (long)ul.TotalXp), - MaxLevel = g.Max(ul => (int?)ul.Level) - }) - .FirstOrDefaultAsync(ct); - - result.Add(new McpUserItem( - user.Id, - user.DiscordId, - user.Username, - user.InsertDate, - user.Balance, - levels?.Messages ?? 0, - levels?.Xp ?? 0, - levels?.MaxLevel)); - } - - return result.AsReadOnly(); - } - - /// - /// Gets a page of quotes. - /// - public async Task GetQuotesAsync( + public async Task GetApprovedQuotesAsync( int page = 1, string sort = "newest", - bool approvedOnly = true, int? guildId = null, CancellationToken ct = default) { - IQueryable query = dbContext.Quotes.AsNoTracking().Where(q => !q.Removed); + if (page < 1) + throw new ArgumentOutOfRangeException(nameof(page), "Page must be greater than zero."); + if (guildId is <= 0) + throw new ArgumentOutOfRangeException(nameof(guildId), "Guild id must be greater than zero."); - if (guildId.HasValue) - query = query.Where(q => q.GuildId == guildId.Value); + IQueryable query = dbContext.Quotes + .AsNoTracking() + .Where(quote => quote.Approved && !quote.Removed); - if (approvedOnly) - query = query.Where(q => q.Approved); + if (guildId.HasValue) + query = query.Where(quote => quote.GuildId == guildId.Value); int total = await query.CountAsync(ct); - int totalPages = (int)Math.Ceiling(total / (double)10); - if (totalPages == 0) totalPages = 1; - - if (page < 1) page = 1; - if (page > totalPages) page = totalPages; + int totalPages = Math.Max(1, (int)Math.Ceiling(total / (double)QuotePageSize)); + int effectivePage = Math.Min(page, totalPages); query = sort.ToLowerInvariant() switch { - "oldest" => query.OrderBy(q => q.InsertDate), - "score" => query.OrderByDescending(q => q.Scores.Sum(s => (int)s.Score)), - _ => query.OrderByDescending(q => q.InsertDate), + "newest" => query.OrderByDescending(quote => quote.InsertDate).ThenByDescending(quote => quote.Id), + "oldest" => query.OrderBy(quote => quote.InsertDate).ThenBy(quote => quote.Id), + "score" => query.OrderByDescending(quote => quote.Scores.Sum(score => (int)score.Score)) + .ThenByDescending(quote => quote.Id), + _ => throw new ArgumentException("Sort must be newest, oldest, or score.", nameof(sort)) }; List quotes = await query - .Skip((page - 1) * 10) - .Take(10) + .Skip((effectivePage - 1) * QuotePageSize) + .Take(QuotePageSize) .ToListAsync(ct); if (quotes.Count == 0) - return new McpQuotePage(page, totalPages, total, []); + return new McpQuotePage(effectivePage, totalPages, total, []); - List quoteIds = [.. quotes.Select(q => q.Id)]; + List quoteIds = [.. quotes.Select(quote => quote.Id)]; Dictionary scoreMap = await dbContext.QuoteScores .AsNoTracking() - .Where(qs => quoteIds.Contains(qs.QuoteId)) - .GroupBy(qs => qs.QuoteId) - .Select(g => new { QuoteId = g.Key, Score = g.Sum(qs => qs.Score) }) - .ToDictionaryAsync(g => g.QuoteId, g => g.Score, ct); + .Where(score => quoteIds.Contains(score.QuoteId)) + .GroupBy(score => score.QuoteId) + .Select(group => new { QuoteId = group.Key, Score = group.Sum(score => score.Score) }) + .ToDictionaryAsync(group => group.QuoteId, group => group.Score, ct); - List userIds = [.. quotes.Select(q => q.UserId).Distinct()]; + List userIds = [.. quotes.Select(quote => quote.UserId).Distinct()]; Dictionary userMap = await dbContext.Users .AsNoTracking() - .Where(u => userIds.Contains(u.Id)) - .ToDictionaryAsync(u => u.Id, u => u.Username, ct); - - var items = quotes.Select(q => new McpQuoteItem( - q.Id, - q.GuildId, - q.UserId, - userMap.GetValueOrDefault(q.UserId, "Unknown"), - q.Content ?? string.Empty, - q.InsertDate, - q.Approved, - q.Removed, - scoreMap.GetValueOrDefault(q.Id) - )).ToList(); - - return new McpQuotePage(page, totalPages, total, items.AsReadOnly()); + .Where(user => userIds.Contains(user.Id)) + .ToDictionaryAsync(user => user.Id, user => user.Username, ct); + + IReadOnlyList items = [.. quotes.Select(quote => new McpQuoteItem( + quote.Id, + quote.GuildId, + userMap.GetValueOrDefault(quote.UserId, "Unknown"), + quote.Content ?? string.Empty, + quote.InsertDate, + scoreMap.GetValueOrDefault(quote.Id)))]; + + return new McpQuotePage(effectivePage, totalPages, total, items); } - /// - /// Gets details for a single quote. - /// - public async Task GetQuoteByIdAsync(int quoteId, CancellationToken ct = default) + public async Task GetApprovedQuoteAsync( + int quoteId, + CancellationToken ct = default) { + if (quoteId <= 0) + throw new ArgumentOutOfRangeException(nameof(quoteId), "Quote id must be greater than zero."); + Quote? quote = await dbContext.Quotes .AsNoTracking() - .FirstOrDefaultAsync(q => q.Id == quoteId && !q.Removed, ct); + .FirstOrDefaultAsync(candidate => + candidate.Id == quoteId && candidate.Approved && !candidate.Removed, + ct); - if (quote == null) return null; + if (quote is null) + return null; int totalScore = await dbContext.QuoteScores .AsNoTracking() - .Where(qs => qs.QuoteId == quote.Id) - .SumAsync(qs => (int?)qs.Score, ct) ?? 0; + .Where(score => score.QuoteId == quote.Id) + .SumAsync(score => (int?)score.Score, ct) ?? 0; string author = await dbContext.Users .AsNoTracking() - .Where(u => u.Id == quote.UserId) - .Select(u => u.Username) + .Where(user => user.Id == quote.UserId) + .Select(user => user.Username) .FirstOrDefaultAsync(ct) ?? "Unknown"; return new McpQuoteDetail( @@ -397,165 +180,67 @@ public async Task GetQuotesAsync( quote.GuildId, quote.Content ?? string.Empty, quote.InsertDate, - quote.Approved, - quote.Removed, totalScore, author); } - /// - /// Gets recent moderation/relevant log entries. - /// - public async Task> GetRecentLogsAsync( - int limit = 20, - string? severity = null, - CancellationToken ct = default) - { - IQueryable query = dbContext.Logs.AsNoTracking(); - - if (!string.IsNullOrWhiteSpace(severity)) - { - if (Enum.TryParse(severity, true, out var parsedSeverity)) - query = query.Where(l => l.Severity == (int)parsedSeverity); - } - - if (limit < 1) limit = 20; - if (limit > 100) limit = 100; - - var logs = await query - .OrderByDescending(l => l.InsertDate) - .Take(limit) - .ToListAsync(ct); - - return logs.Select(l => new McpModerationEntry( - l.Id, - ((Discord.LogSeverity)l.Severity).ToString(), - l.Message, - l.InsertDate - )).ToList().AsReadOnly(); - } - - /// - /// Gets stock market summary with top gainers and losers. - /// - public async Task GetStockSummaryAsync(int moverLimit = 5, CancellationToken ct = default) - { - int totalStocks = await dbContext.Stocks.AsNoTracking().CountAsync(ct); - - var stocks = await dbContext.Stocks - .AsNoTracking() - .Where(s => s.Price > 0) - .ToListAsync(ct); - - var gainers = stocks - .Where(s => s.DailyChangePercent > 0) - .OrderByDescending(s => s.DailyChangePercent) - .Take(moverLimit) - .Select(s => new McpStockItem( - s.Id, - s.EntityType.ToString(), - s.EntityId, - ResolveStockName(s), - Math.Round(s.Price, 2), - Math.Round(s.DailyChangePercent, 2))) - .ToList().AsReadOnly(); - - var losers = stocks - .Where(s => s.DailyChangePercent < 0) - .OrderBy(s => s.DailyChangePercent) - .Take(moverLimit) - .Select(s => new McpStockItem( - s.Id, - s.EntityType.ToString(), - s.EntityId, - ResolveStockName(s), - Math.Round(s.Price, 2), - Math.Round(s.DailyChangePercent, 2))) - .ToList().AsReadOnly(); - - return new McpStockSummary(totalStocks, gainers, losers); - } - - /// - /// Gets activity leaderboard data. - /// public async Task> GetLeaderboardAsync( - string metric = "xp", - int? guildId = null, + string metric, + int guildId, int days = 30, int limit = 10, CancellationToken ct = default) { - if (limit < 1) limit = 10; - if (limit > 50) limit = 50; + if (guildId <= 0) + throw new ArgumentOutOfRangeException(nameof(guildId), "Guild id must be greater than zero."); + if (days is < 1 or > 365) + throw new ArgumentOutOfRangeException(nameof(days), "Days must be between 1 and 365."); + if (limit is < 1 or > 50) + throw new ArgumentOutOfRangeException(nameof(limit), "Limit must be between 1 and 50."); DateTime since = DateTime.UtcNow.AddDays(-days); - IQueryable activityQuery = dbContext.UserActivity .AsNoTracking() - .Where(ua => ua.InsertDate >= since); + .Where(activity => + activity.GuildId == guildId && activity.InsertDate >= since); - if (guildId.HasValue) - activityQuery = activityQuery.Where(ua => ua.GuildId == guildId.Value); - - var raw = metric.ToLowerInvariant() switch + var values = metric.ToLowerInvariant() switch { "messages" => await activityQuery - .GroupBy(ua => ua.UserId) - .Select(g => new { UserId = g.Key, Value = g.LongCount() }) - .OrderByDescending(x => x.Value) + .GroupBy(activity => activity.UserId) + .Select(group => new { UserId = group.Key, Value = group.LongCount() }) + .OrderByDescending(item => item.Value) + .ThenBy(item => item.UserId) .Take(limit) .ToListAsync(ct), - _ => await activityQuery - .GroupBy(ua => ua.UserId) - .Select(g => new { UserId = g.Key, Value = g.Sum(ua => (long)ua.XpGained) }) - .OrderByDescending(x => x.Value) + "xp" => await activityQuery + .GroupBy(activity => activity.UserId) + .Select(group => new { UserId = group.Key, Value = group.Sum(activity => (long)activity.XpGained) }) + .OrderByDescending(item => item.Value) + .ThenBy(item => item.UserId) .Take(limit) - .ToListAsync(ct) + .ToListAsync(ct), + _ => throw new ArgumentException("Metric must be xp or messages.", nameof(metric)) }; - if (raw.Count == 0) + if (values.Count == 0) return []; - List userIds = [.. raw.Select(x => x.UserId)]; - var users = await dbContext.Users - .AsNoTracking() - .Where(u => userIds.Contains(u.Id)) - .ToDictionaryAsync(u => u.Id, ct); - - var levels = await dbContext.UserLevels + List userIds = [.. values.Select(item => item.UserId)]; + Dictionary users = await dbContext.Users .AsNoTracking() - .Where(ul => userIds.Contains(ul.UserId)) - .GroupBy(ul => ul.UserId) - .Select(g => new { UserId = g.Key, MaxLevel = g.Max(ul => (int?)ul.Level) }) - .ToDictionaryAsync(g => g.UserId, g => g.MaxLevel, ct); - - return raw.Select((x, i) => new McpLeaderboardEntry( - i + 1, - x.UserId, - users.GetValueOrDefault(x.UserId)?.DiscordId ?? 0, - users.GetValueOrDefault(x.UserId)?.Username ?? "Unknown", - x.Value, - levels.GetValueOrDefault(x.UserId) - )).ToList().AsReadOnly(); - } + .Where(user => userIds.Contains(user.Id)) + .ToDictionaryAsync(user => user.Id, user => user.Username, ct); - private async Task GetBotSettingDecimalAsync(string key, decimal defaultValue, CancellationToken ct) - { - BotSetting? setting = await dbContext.BotSettings + Dictionary levels = await dbContext.UserLevels .AsNoTracking() - .FirstOrDefaultAsync(s => s.Key == key, ct); - - if (setting == null || string.IsNullOrWhiteSpace(setting.Value)) - return defaultValue; + .Where(level => level.GuildId == guildId && userIds.Contains(level.UserId)) + .ToDictionaryAsync(level => level.UserId, level => (int?)level.Level, ct); - return decimal.TryParse(setting.Value, out decimal val) ? val : defaultValue; - } - - private static string ResolveStockName(Stock stock) - { - // For stocks tied to entities, try to provide a meaningful name. - // If the entity isn't loaded, fall back to the stock id. - return $"Stock #{stock.Id} ({stock.EntityType})"; + return [.. values.Select((item, index) => new McpLeaderboardEntry( + index + 1, + users.GetValueOrDefault(item.UserId, "Unknown"), + item.Value, + levels.GetValueOrDefault(item.UserId)))]; } -} \ No newline at end of file +} diff --git a/MCP/McpTools.cs b/MCP/McpTools.cs new file mode 100644 index 0000000..b235250 --- /dev/null +++ b/MCP/McpTools.cs @@ -0,0 +1,83 @@ +using System.ComponentModel; +using ModelContextProtocol.Server; + +namespace Morpheus.MCP; + +[McpServerToolType] +public sealed class McpTools(McpService service) +{ + [McpServerTool( + Name = "get_activity_overview", + ReadOnly = true, + Destructive = false, + OpenWorld = false, + UseStructuredContent = true)] + [Description("Get aggregate Morpheus activity totals and the last 30 days of activity.")] + public Task GetActivityOverviewAsync( + CancellationToken cancellationToken = default) => + service.GetActivityOverviewAsync(cancellationToken); + + [McpServerTool( + Name = "get_guild_info", + ReadOnly = true, + Destructive = false, + OpenWorld = false, + UseStructuredContent = true)] + [Description("Get aggregate statistics for one Discord guild. Provide an internal guild id or a Discord guild id.")] + public Task GetGuildInfoAsync( + [Description("Positive internal Morpheus guild id.")] int? guildId = null, + [Description("Positive Discord guild id, represented as a decimal string.")] string? discordId = null, + CancellationToken cancellationToken = default) + { + ulong? parsedDiscordId = null; + if (!string.IsNullOrWhiteSpace(discordId)) + { + if (!ulong.TryParse(discordId, out ulong value) || value == 0) + throw new ArgumentException("discordId must be a positive decimal Discord id.", nameof(discordId)); + parsedDiscordId = value; + } + + return service.GetGuildInfoAsync(guildId, parsedDiscordId, cancellationToken); + } + + [McpServerTool( + Name = "get_approved_quotes", + ReadOnly = true, + Destructive = false, + OpenWorld = false, + UseStructuredContent = true)] + [Description("Get a page of approved, non-removed quotes. Pending and removed quotes are never returned.")] + public Task GetApprovedQuotesAsync( + [Description("Page number, starting at 1.")] int page = 1, + [Description("Sort order: newest, oldest, or score.")] string sort = "newest", + [Description("Optional positive internal guild id.")] int? guildId = null, + CancellationToken cancellationToken = default) => + service.GetApprovedQuotesAsync(page, sort, guildId, cancellationToken); + + [McpServerTool( + Name = "get_approved_quote", + ReadOnly = true, + Destructive = false, + OpenWorld = false, + UseStructuredContent = true)] + [Description("Get one approved, non-removed quote by its internal id.")] + public Task GetApprovedQuoteAsync( + [Description("Positive internal quote id.")] int quoteId, + CancellationToken cancellationToken = default) => + service.GetApprovedQuoteAsync(quoteId, cancellationToken); + + [McpServerTool( + Name = "get_guild_leaderboard", + ReadOnly = true, + Destructive = false, + OpenWorld = false, + UseStructuredContent = true)] + [Description("Get an XP or message leaderboard for one guild and a bounded lookback period.")] + public Task> GetGuildLeaderboardAsync( + [Description("Metric: xp or messages.")] string metric, + [Description("Positive internal Morpheus guild id.")] int guildId, + [Description("Lookback period from 1 through 365 days.")] int days = 30, + [Description("Number of entries from 1 through 50.")] int limit = 10, + CancellationToken cancellationToken = default) => + service.GetLeaderboardAsync(metric, guildId, days, limit, cancellationToken); +} diff --git a/Morpheus.Tests/McpApiEndpointTests.cs b/Morpheus.Tests/McpApiEndpointTests.cs new file mode 100644 index 0000000..c9ef0c4 --- /dev/null +++ b/Morpheus.Tests/McpApiEndpointTests.cs @@ -0,0 +1,354 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Morpheus.Database; +using Morpheus.Database.Models; +using Morpheus.MCP; + +namespace Morpheus.Tests; + +public class McpApiEndpointTests +{ + private const string ApiKey = "integration-test-mcp-key"; + private const string AllowedOrigin = "https://client.example"; + + [Fact] + public async Task Endpoint_RequiresValidBearerCredentials() + { + await using McpTestServer server = await McpTestServer.CreateAsync(); + + using HttpRequestMessage missing = CreateInitializeRequest(); + using HttpResponseMessage missingResponse = await server.Client.SendAsync(missing); + Assert.Equal(HttpStatusCode.Unauthorized, missingResponse.StatusCode); + Assert.Equal("Bearer", missingResponse.Headers.WwwAuthenticate.Single().Scheme); + + using HttpRequestMessage incorrect = CreateInitializeRequest("wrong-key"); + using HttpResponseMessage incorrectResponse = await server.Client.SendAsync(incorrect); + Assert.Equal(HttpStatusCode.Unauthorized, incorrectResponse.StatusCode); + + using HttpRequestMessage correct = CreateInitializeRequest(ApiKey); + using HttpResponseMessage correctResponse = await server.Client.SendAsync(correct); + Assert.Equal(HttpStatusCode.OK, correctResponse.StatusCode); + } + + [Fact] + public async Task Endpoint_RejectsUnlistedOriginAndAcceptsAllowedOrigin() + { + await using McpTestServer server = await McpTestServer.CreateAsync(); + + using HttpRequestMessage rejected = CreateInitializeRequest(ApiKey, "https://evil.example"); + using HttpResponseMessage rejectedResponse = await server.Client.SendAsync(rejected); + Assert.Equal(HttpStatusCode.Forbidden, rejectedResponse.StatusCode); + + using HttpRequestMessage allowed = CreateInitializeRequest(ApiKey, AllowedOrigin); + using HttpResponseMessage allowedResponse = await server.Client.SendAsync(allowed); + Assert.Equal(HttpStatusCode.OK, allowedResponse.StatusCode); + Assert.Equal(AllowedOrigin, allowedResponse.Headers.GetValues("Access-Control-Allow-Origin").Single()); + } + + [Fact] + public async Task Endpoint_ImplementsInitializeAndToolsList() + { + await using McpTestServer server = await McpTestServer.CreateAsync(); + + using HttpRequestMessage initialize = CreateInitializeRequest(ApiKey); + using HttpResponseMessage initializeResponse = await server.Client.SendAsync(initialize); + JsonDocument initializeJson = await ReadJsonAsync(initializeResponse); + Assert.Equal("2.0", initializeJson.RootElement.GetProperty("jsonrpc").GetString()); + Assert.Equal(1, initializeJson.RootElement.GetProperty("id").GetInt32()); + Assert.True(initializeJson.RootElement.GetProperty("result").TryGetProperty("serverInfo", out _)); + + using HttpRequestMessage listTools = CreateJsonRpcRequest( + 2, + "tools/list", + "{}", + ApiKey); + using HttpResponseMessage listResponse = await server.Client.SendAsync(listTools); + JsonDocument listJson = await ReadJsonAsync(listResponse); + + JsonElement tools = listJson.RootElement + .GetProperty("result") + .GetProperty("tools"); + string[] names = [.. tools.EnumerateArray() + .Select(tool => tool.GetProperty("name").GetString()!)]; + + Assert.Contains("get_activity_overview", names); + Assert.Contains("get_approved_quotes", names); + Assert.Contains("get_guild_leaderboard", names); + Assert.DoesNotContain("get_recent_logs", names); + Assert.DoesNotContain("get_users", names); + Assert.All(tools.EnumerateArray(), tool => + Assert.Equal(JsonValueKind.Object, tool.GetProperty("inputSchema").ValueKind)); + } + + [Fact] + public async Task Endpoint_ExecutesStandardToolsCall() + { + await using McpTestServer server = await McpTestServer.CreateAsync(); + + using HttpRequestMessage call = CreateJsonRpcRequest( + 3, + "tools/call", + """{"name":"get_activity_overview","arguments":{}}""", + ApiKey); + using HttpResponseMessage response = await server.Client.SendAsync(call); + JsonDocument json = await ReadJsonAsync(response); + + Assert.Equal(3, json.RootElement.GetProperty("id").GetInt32()); + JsonElement result = json.RootElement.GetProperty("result"); + if (result.TryGetProperty("isError", out JsonElement isError)) + Assert.False(isError.GetBoolean()); + Assert.True( + result.TryGetProperty("structuredContent", out JsonElement structured), + result.GetRawText()); + Assert.Equal(0, structured.GetProperty("totalMessages").GetInt64()); + } + + [Fact] + public async Task ApprovedQuotesTool_NeverReturnsPendingQuotes() + { + await using McpTestServer server = await McpTestServer.CreateAsync(); + int guildId = await server.SeedApprovedAndPendingQuotesAsync(); + + string parameters = JsonSerializer.Serialize(new + { + name = "get_approved_quotes", + arguments = new { guildId } + }); + using HttpRequestMessage call = CreateJsonRpcRequest( + 4, + "tools/call", + parameters, + ApiKey); + using HttpResponseMessage response = await server.Client.SendAsync(call); + JsonDocument json = await ReadJsonAsync(response); + + JsonElement result = json.RootElement.GetProperty("result"); + Assert.True( + result.TryGetProperty("structuredContent", out JsonElement structured), + result.GetRawText()); + JsonElement item = Assert.Single(structured.GetProperty("items").EnumerateArray()); + Assert.Equal("approved content", item.GetProperty("content").GetString()); + Assert.Equal(1, structured.GetProperty("total").GetInt32()); + } + + [Fact] + public async Task Endpoint_ReturnsTooManyRequestsAfterConfiguredLimit() + { + await using McpTestServer server = await McpTestServer.CreateAsync(requestsPerMinute: 2); + + for (int id = 1; id <= 2; id++) + { + using HttpRequestMessage allowed = CreateInitializeRequest(ApiKey, id: id); + using HttpResponseMessage allowedResponse = await server.Client.SendAsync(allowed); + Assert.Equal(HttpStatusCode.OK, allowedResponse.StatusCode); + } + + using HttpRequestMessage rejected = CreateInitializeRequest(ApiKey, id: 3); + using HttpResponseMessage rejectedResponse = await server.Client.SendAsync(rejected); + Assert.Equal(HttpStatusCode.TooManyRequests, rejectedResponse.StatusCode); + } + + [Fact] + public async Task Endpoint_RateLimitsInvalidCredentials() + { + await using McpTestServer server = await McpTestServer.CreateAsync(requestsPerMinute: 2); + + for (int id = 1; id <= 2; id++) + { + using HttpRequestMessage invalid = CreateInitializeRequest("wrong-key", id: id); + using HttpResponseMessage invalidResponse = await server.Client.SendAsync(invalid); + Assert.Equal(HttpStatusCode.Unauthorized, invalidResponse.StatusCode); + } + + using HttpRequestMessage rejected = CreateInitializeRequest("wrong-key", id: 3); + using HttpResponseMessage rejectedResponse = await server.Client.SendAsync(rejected); + Assert.Equal(HttpStatusCode.TooManyRequests, rejectedResponse.StatusCode); + } + + [Fact] + public async Task CorsPreflight_AllowsCurrentMcpHeadersFromConfiguredOrigin() + { + await using McpTestServer server = await McpTestServer.CreateAsync(); + using HttpRequestMessage preflight = new(HttpMethod.Options, "/api/mcp"); + preflight.Headers.Add("Origin", AllowedOrigin); + preflight.Headers.Add("Access-Control-Request-Method", "POST"); + preflight.Headers.Add( + "Access-Control-Request-Headers", + "authorization,content-type,mcp-protocol-version,mcp-method,mcp-name"); + + using HttpResponseMessage response = await server.Client.SendAsync(preflight); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + Assert.Equal(AllowedOrigin, response.Headers.GetValues("Access-Control-Allow-Origin").Single()); + string allowedHeaders = response.Headers.GetValues("Access-Control-Allow-Headers").Single(); + Assert.Contains("mcp-method", allowedHeaders, StringComparison.OrdinalIgnoreCase); + Assert.Contains("mcp-name", allowedHeaders, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Endpoint_AcceptsCurrentProtocolRequestHeaders() + { + await using McpTestServer server = await McpTestServer.CreateAsync(); + using HttpRequestMessage request = CreateJsonRpcRequest( + 5, + "tools/list", + """{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}""", + ApiKey); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); + request.Headers.Add("Mcp-Method", "tools/list"); + + using HttpResponseMessage response = await server.Client.SendAsync(request); + JsonDocument json = await ReadJsonAsync(response); + + Assert.Equal(5, json.RootElement.GetProperty("id").GetInt32()); + Assert.True(json.RootElement.GetProperty("result").TryGetProperty("tools", out _)); + } + + [Fact] + public void Options_RejectMalformedOriginsAndNonPositiveRateLimit() + { + Assert.Throws(() => + new McpApiOptions(["https://client.example/path"], ApiKey, 60).Validate()); + Assert.Throws(() => + new McpApiOptions([AllowedOrigin], ApiKey, 0).Validate()); + } + + [Fact] + public void Options_DefaultListenerIsLoopbackOnly() + { + McpApiOptions options = new([], string.Empty, 60); + + Assert.Equal("http://127.0.0.1:5268", options.ListenerUrls); + } + + private static HttpRequestMessage CreateInitializeRequest( + string? apiKey = null, + string? origin = null, + int id = 1) => + CreateJsonRpcRequest( + id, + "initialize", + """{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"Morpheus.Tests","version":"1.0"}}""", + apiKey, + origin); + + private static HttpRequestMessage CreateJsonRpcRequest( + int id, + string method, + string parameters, + string? apiKey, + string? origin = null) + { + HttpRequestMessage request = new(HttpMethod.Post, "/api/mcp") + { + Content = new StringContent( + $"{{\"jsonrpc\":\"2.0\",\"id\":{id},\"method\":{JsonSerializer.Serialize(method)},\"params\":{parameters}}}", + Encoding.UTF8, + "application/json") + }; + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); + if (apiKey is not null) + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + if (origin is not null) + request.Headers.Add("Origin", origin); + return request; + } + + private static async Task ReadJsonAsync(HttpResponseMessage response) + { + string content = await response.Content.ReadAsStringAsync(); + Assert.True(response.IsSuccessStatusCode, $"Unexpected {(int)response.StatusCode}: {content}"); + string payload = content.StartsWith("event:", StringComparison.Ordinal) + ? content.Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Single(line => line.StartsWith("data:", StringComparison.Ordinal))[5..] + .Trim() + : content; + return JsonDocument.Parse(payload); + } + + private sealed class McpTestServer( + SqliteConnection connection, + WebApplication app, + HttpClient client) : IAsyncDisposable + { + public HttpClient Client { get; } = client; + + public async Task SeedApprovedAndPendingQuotesAsync() + { + await using AsyncServiceScope scope = app.Services.CreateAsyncScope(); + DB db = scope.ServiceProvider.GetRequiredService(); + Guild guild = new() { DiscordId = 123, Name = "MCP Test Guild" }; + User user = new() { DiscordId = 456, Username = "MCP Test User" }; + db.Guilds.Add(guild); + db.Users.Add(user); + await db.SaveChangesAsync(); + db.Quotes.AddRange( + new Quote + { + GuildId = guild.Id, + UserId = user.Id, + Content = "approved content", + Approved = true + }, + new Quote + { + GuildId = guild.Id, + UserId = user.Id, + Content = "pending content", + Approved = false + }); + await db.SaveChangesAsync(); + return guild.Id; + } + + public static async Task CreateAsync(int requestsPerMinute = 60) + { + SqliteConnection connection = new("DataSource=:memory:"); + await connection.OpenAsync(); + + WebApplicationBuilder builder = WebApplication.CreateBuilder(new WebApplicationOptions + { + EnvironmentName = "Testing", + ApplicationName = typeof(McpApiExtensions).Assembly.GetName().Name + }); + builder.WebHost.UseTestServer(); + builder.Logging.ClearProviders(); + builder.Services.AddDbContext(options => options.UseSqlite(connection)); + builder.Services.AddMcpApi(new McpApiOptions( + [AllowedOrigin], + ApiKey, + requestsPerMinute)); + + WebApplication app = builder.Build(); + app.UseCors(); + app.UseMcpApiSecurity(); + app.MapMcpApi(); + + await app.StartAsync(); + await using (AsyncServiceScope scope = app.Services.CreateAsyncScope()) + { + DB db = scope.ServiceProvider.GetRequiredService(); + await db.Database.EnsureCreatedAsync(); + } + + return new McpTestServer(connection, app, app.GetTestClient()); + } + + public async ValueTask DisposeAsync() + { + Client.Dispose(); + await app.DisposeAsync(); + await connection.DisposeAsync(); + } + } +} diff --git a/Morpheus.Tests/McpServiceTests.cs b/Morpheus.Tests/McpServiceTests.cs index 4dcb4d4..ab0b88a 100644 --- a/Morpheus.Tests/McpServiceTests.cs +++ b/Morpheus.Tests/McpServiceTests.cs @@ -9,362 +9,133 @@ namespace Morpheus.Tests; public class McpServiceTests { [Fact] - public async Task GetUserStatsAsync_ReturnsUserStats_WhenUserExists() + public async Task GetApprovedQuotesAsync_NeverReturnsPendingOrRemovedQuotes() { await using SqliteTestDb testDb = await CreateSqliteDbAsync(); (Guild guild, User user) = await SeedBaseAsync(testDb.Db); - testDb.Db.UserLevels.Add(new UserLevels - { - GuildId = guild.Id, - UserId = user.Id, - TotalXp = 500, - UserMessageCount = 10, - Level = 3 - }); - testDb.Db.UserActivity.Add(new UserActivity - { - GuildId = guild.Id, - UserId = user.Id, - DiscordChannelId = 1, - DiscordMessageId = 2, - XpGained = 50, - MessageLength = 25, - InsertDate = DateTime.UtcNow.AddDays(-1) - }); + testDb.Db.Quotes.AddRange( + new Quote + { + GuildId = guild.Id, + UserId = user.Id, + Content = "approved", + Approved = true + }, + new Quote + { + GuildId = guild.Id, + UserId = user.Id, + Content = "pending", + Approved = false + }, + new Quote + { + GuildId = guild.Id, + UserId = user.Id, + Content = "removed", + Approved = true, + Removed = true + }); await testDb.Db.SaveChangesAsync(); - McpService service = CreateService(testDb.Db); - - McpUserStats? stats = await service.GetUserStatsAsync(user.Id, null); - - Assert.NotNull(stats); - Assert.Equal(user.Id, stats.Id); - Assert.Equal(user.DiscordId, stats.DiscordId); - Assert.Equal(user.Username, stats.Username); - Assert.Equal(1000m, stats.Balance); - Assert.Equal(10, stats.TotalMessages); - Assert.Equal(500, stats.TotalXp); - Assert.Equal(3, stats.Level); - } - - [Fact] - public async Task GetUserStatsAsync_ReturnsNull_WhenUserNotFound() - { - await using SqliteTestDb testDb = await CreateSqliteDbAsync(); - McpService service = CreateService(testDb.Db); - - McpUserStats? stats = await service.GetUserStatsAsync(999, null); + McpQuotePage result = await new McpService(testDb.Db).GetApprovedQuotesAsync(); - Assert.Null(stats); + McpQuoteItem item = Assert.Single(result.Items); + Assert.Equal("approved", item.Content); + Assert.Equal(1, result.Total); } [Fact] - public async Task GetGuildInfoAsync_ReturnsGuildInfo_WhenGuildExists() + public async Task GetApprovedQuoteAsync_ReturnsNullForPendingOrRemovedQuote() { await using SqliteTestDb testDb = await CreateSqliteDbAsync(); (Guild guild, User user) = await SeedBaseAsync(testDb.Db); - testDb.Db.UserLevels.Add(new UserLevels + Quote pending = new() { GuildId = guild.Id, UserId = user.Id, - TotalXp = 200, - UserMessageCount = 5, - Level = 2 - }); - testDb.Db.Quotes.Add(new Quote + Content = "pending", + Approved = false + }; + Quote removed = new() { GuildId = guild.Id, UserId = user.Id, - Content = "test quote", - Approved = true - }); - await testDb.Db.SaveChangesAsync(); - - McpService service = CreateService(testDb.Db); - - McpGuildInfo? info = await service.GetGuildInfoAsync(guild.Id, null); - - Assert.NotNull(info); - Assert.Equal(guild.Id, info.Id); - Assert.Equal(guild.DiscordId, info.DiscordId); - Assert.Equal(guild.Name, info.Name); - Assert.Equal(guild.Prefix, info.Prefix); - Assert.Equal(1, info.TrackedUsers); - Assert.Equal(5, info.Messages); - Assert.Equal(200, info.Xp); - Assert.Equal(1, info.ApprovedQuotes); - } - - [Fact] - public async Task GetEconomySummaryAsync_ReturnsSummary() - { - await using SqliteTestDb testDb = await CreateSqliteDbAsync(); - await SeedBaseAsync(testDb.Db); - - // Add a second user with balance - testDb.Db.Users.Add(new User - { - DiscordId = 999, - Username = "user2", - Balance = 500m - }); + Content = "removed", + Approved = true, + Removed = true + }; + testDb.Db.Quotes.AddRange(pending, removed); await testDb.Db.SaveChangesAsync(); - McpService service = CreateService(testDb.Db); + McpService service = new(testDb.Db); - McpEconomySummary summary = await service.GetEconomySummaryAsync(); - - Assert.Equal(2, summary.TotalUsers); - Assert.Equal(1500m, summary.TotalBalance); - Assert.Equal(750m, summary.AverageBalance); - Assert.Equal(0m, summary.UbiPoolSize); + Assert.Null(await service.GetApprovedQuoteAsync(pending.Id)); + Assert.Null(await service.GetApprovedQuoteAsync(removed.Id)); } [Fact] - public async Task GetActivityOverviewAsync_ReturnsOverview() + public async Task GetGuildInfoAsync_ReturnsOnlyAggregateGuildData() { await using SqliteTestDb testDb = await CreateSqliteDbAsync(); (Guild guild, User user) = await SeedBaseAsync(testDb.Db); - testDb.Db.UserLevels.Add(new UserLevels { GuildId = guild.Id, UserId = user.Id, - TotalXp = 300, - UserMessageCount = 7, - Level = 2 - }); - testDb.Db.UserActivity.Add(new UserActivity - { - GuildId = guild.Id, - UserId = user.Id, - DiscordChannelId = 1, - DiscordMessageId = 2, - XpGained = 100, - MessageLength = 50, - InsertDate = DateTime.UtcNow.AddHours(-1) - }); - await testDb.Db.SaveChangesAsync(); - - McpService service = CreateService(testDb.Db); - - McpActivityOverview overview = await service.GetActivityOverviewAsync(); - - Assert.Equal(7, overview.TotalMessages); - Assert.Equal(300, overview.TotalXp); - Assert.Equal(1, overview.ActiveUsersLast30Days); - Assert.Equal(1, overview.MessagesLast30Days); - Assert.Equal(100, overview.XpLast30Days); - Assert.Equal(1, overview.TotalServers); - Assert.Equal(1, overview.TotalKnownUsers); - } - - [Fact] - public async Task GetGuildsAsync_ReturnsGuildList() - { - await using SqliteTestDb testDb = await CreateSqliteDbAsync(); - await SeedBaseAsync(testDb.Db); - - // Add a second guild - testDb.Db.Guilds.Add(new Guild - { - DiscordId = 222, - Name = "Server Two" - }); - await testDb.Db.SaveChangesAsync(); - - McpService service = CreateService(testDb.Db); - - IReadOnlyList guilds = await service.GetGuildsAsync(); - - Assert.Equal(2, guilds.Count); - } - - [Fact] - public async Task GetUsersAsync_ReturnsPaginatedUsers() - { - await using SqliteTestDb testDb = await CreateSqliteDbAsync(); - await SeedBaseAsync(testDb.Db); - - McpService service = CreateService(testDb.Db); - - IReadOnlyList users = await service.GetUsersAsync(page: 1, limit: 10); - - Assert.Single(users); - Assert.Equal(1000m, users[0].Balance); - } - - [Fact] - public async Task GetQuotesAsync_ReturnsQuotes() - { - await using SqliteTestDb testDb = await CreateSqliteDbAsync(); - (Guild guild, User user) = await SeedBaseAsync(testDb.Db); - - testDb.Db.Quotes.Add(new Quote - { - GuildId = guild.Id, - UserId = user.Id, - Content = "Hello world", - Approved = true - }); - testDb.Db.Quotes.Add(new Quote - { - GuildId = guild.Id, - UserId = user.Id, - Content = "Second quote", - Approved = false + TotalXp = 500, + UserMessageCount = 10, + Level = 3 }); await testDb.Db.SaveChangesAsync(); - McpService service = CreateService(testDb.Db); - - // Get approved only - McpQuotePage page = await service.GetQuotesAsync(approvedOnly: true); - - Assert.Equal(1, page.Total); - Assert.Single(page.Items); - Assert.Equal("Hello world", page.Items[0].Content); + McpGuildInfo? result = await new McpService(testDb.Db) + .GetGuildInfoAsync(guild.Id, null); - // Get all (including pending) - McpQuotePage allPage = await service.GetQuotesAsync(approvedOnly: false); - - Assert.Equal(2, allPage.Total); + Assert.NotNull(result); + Assert.Equal(guild.Id, result.Id); + Assert.Equal("Test Server", result.Name); + Assert.Equal(1, result.TrackedUsers); + Assert.Equal(10, result.Messages); + Assert.Equal(500, result.Xp); } [Fact] - public async Task GetQuoteByIdAsync_ReturnsQuote() + public async Task GetLeaderboardAsync_IsGuildScopedAndValidatesBounds() { await using SqliteTestDb testDb = await CreateSqliteDbAsync(); (Guild guild, User user) = await SeedBaseAsync(testDb.Db); - - Quote quote = new() - { - GuildId = guild.Id, - UserId = user.Id, - Content = "Test quote detail", - Approved = true - }; - testDb.Db.Quotes.Add(quote); + Guild otherGuild = new() { DiscordId = 999, Name = "Other" }; + testDb.Db.Guilds.Add(otherGuild); await testDb.Db.SaveChangesAsync(); - McpService service = CreateService(testDb.Db); - - McpQuoteDetail? detail = await service.GetQuoteByIdAsync(quote.Id); - - Assert.NotNull(detail); - Assert.Equal(quote.Id, detail.Id); - Assert.Equal("Test quote detail", detail.Content); - Assert.Equal(user.Username, detail.Author); - } - - [Fact] - public async Task GetQuoteByIdAsync_ReturnsNull_WhenNotFound() - { - await using SqliteTestDb testDb = await CreateSqliteDbAsync(); - McpService service = CreateService(testDb.Db); - - McpQuoteDetail? detail = await service.GetQuoteByIdAsync(999); - - Assert.Null(detail); - } - - [Fact] - public async Task GetRecentLogsAsync_ReturnsLogs() - { - await using SqliteTestDb testDb = await CreateSqliteDbAsync(); - - testDb.Db.Logs.Add(new Log { Message = "info log", Severity = (int)Discord.LogSeverity.Info, InsertDate = DateTime.UtcNow }); - testDb.Db.Logs.Add(new Log { Message = "warning log", Severity = (int)Discord.LogSeverity.Warning, InsertDate = DateTime.UtcNow }); - testDb.Db.Logs.Add(new Log { Message = "error log", Severity = (int)Discord.LogSeverity.Error, InsertDate = DateTime.UtcNow }); + testDb.Db.UserActivity.AddRange( + CreateActivity(guild.Id, user.Id, 10), + CreateActivity(otherGuild.Id, user.Id, 1000)); await testDb.Db.SaveChangesAsync(); - McpService service = CreateService(testDb.Db); + McpService service = new(testDb.Db); + IReadOnlyList result = await service.GetLeaderboardAsync( + "xp", guild.Id, 30, 10); - IReadOnlyList logs = await service.GetRecentLogsAsync(limit: 10); - - Assert.Equal(3, logs.Count); - - // Filter by severity - IReadOnlyList errorLogs = await service.GetRecentLogsAsync(limit: 10, severity: "Error"); - Assert.Single(errorLogs); - Assert.Equal("error log", errorLogs[0].Message); + McpLeaderboardEntry entry = Assert.Single(result); + Assert.Equal(10, entry.Value); + await Assert.ThrowsAsync( + () => service.GetLeaderboardAsync("xp", guild.Id, 366, 10)); } - [Fact] - public async Task GetLeaderboardAsync_ReturnsRankings() + private static UserActivity CreateActivity(int guildId, int userId, int xp) => new() { - await using SqliteTestDb testDb = await CreateSqliteDbAsync(); - (Guild guild, User user) = await SeedBaseAsync(testDb.Db); - - testDb.Db.UserActivity.Add(new UserActivity - { - GuildId = guild.Id, - UserId = user.Id, - DiscordChannelId = 1, - DiscordMessageId = 2, - XpGained = 100, - MessageLength = 25, - InsertDate = DateTime.UtcNow.AddHours(-1) - }); - await testDb.Db.SaveChangesAsync(); - - McpService service = CreateService(testDb.Db); - - IReadOnlyList leaderboard = await service.GetLeaderboardAsync( - metric: "xp", guildId: guild.Id, days: 30, limit: 10); - - Assert.Single(leaderboard); - Assert.Equal(1, leaderboard[0].Rank); - Assert.Equal(user.Id, leaderboard[0].UserId); - Assert.Equal(100, leaderboard[0].Value); - } - - [Fact] - public async Task GetStockSummaryAsync_ReturnsSummary() - { - await using SqliteTestDb testDb = await CreateSqliteDbAsync(); - (Guild _, User user) = await SeedBaseAsync(testDb.Db); - - testDb.Db.Stocks.Add(new Stock - { - EntityType = Database.Enums.StockEntityType.User, - EntityId = user.Id, - Price = 120m, - PreviousPrice = 100m, - DailyChangePercent = 20m - }); - testDb.Db.Stocks.Add(new Stock - { - EntityType = Database.Enums.StockEntityType.Guild, - EntityId = 2, - Price = 80m, - PreviousPrice = 100m, - DailyChangePercent = -20m - }); - testDb.Db.Stocks.Add(new Stock - { - EntityType = Database.Enums.StockEntityType.Guild, - EntityId = 3, - Price = 50m, - PreviousPrice = 100m, - DailyChangePercent = -50m - }); - await testDb.Db.SaveChangesAsync(); - - McpService service = CreateService(testDb.Db); - - McpStockSummary summary = await service.GetStockSummaryAsync(moverLimit: 5); - - Assert.Equal(3, summary.TotalStocks); - Assert.Single(summary.TopGainers); - Assert.Equal(2, summary.TopLosers.Count); - Assert.Equal(20m, summary.TopGainers[0].DailyChangePercent); - Assert.Equal(-50m, summary.TopLosers[0].DailyChangePercent); - } - - // ── Test Infrastructure ── + GuildId = guildId, + UserId = userId, + DiscordChannelId = 1, + DiscordMessageId = (ulong)Random.Shared.Next(1, int.MaxValue), + XpGained = xp, + MessageLength = 25, + InsertDate = DateTime.UtcNow.AddHours(-1) + }; private sealed class SqliteTestDb(SqliteConnection connection, DB db) : IAsyncDisposable { @@ -381,14 +152,8 @@ private static async Task CreateSqliteDbAsync() { SqliteConnection connection = new("DataSource=:memory:"); await connection.OpenAsync(); - - DbContextOptions options = new DbContextOptionsBuilder() - .UseSqlite(connection) - .Options; - - DB db = new(options); + DB db = new(new DbContextOptionsBuilder().UseSqlite(connection).Options); await db.Database.EnsureCreatedAsync(); - return new SqliteTestDb(connection, db); } @@ -400,20 +165,15 @@ private static async Task CreateSqliteDbAsync() Name = "Test Server", Prefix = "m!" }; - db.Guilds.Add(guild); - await db.SaveChangesAsync(); - User user = new() { DiscordId = 456, Username = "TestUser", Balance = 1000m }; + db.Guilds.Add(guild); db.Users.Add(user); await db.SaveChangesAsync(); - return (guild, user); } - - private static McpService CreateService(DB db) => new(db); -} \ No newline at end of file +} diff --git a/Morpheus.Tests/Morpheus.Tests.csproj b/Morpheus.Tests/Morpheus.Tests.csproj index ed5dab1..79b4405 100644 --- a/Morpheus.Tests/Morpheus.Tests.csproj +++ b/Morpheus.Tests/Morpheus.Tests.csproj @@ -9,6 +9,7 @@ + diff --git a/Morpheus.csproj b/Morpheus.csproj index 563273f..1bbec9c 100644 --- a/Morpheus.csproj +++ b/Morpheus.csproj @@ -13,6 +13,7 @@ + diff --git a/Program.cs b/Program.cs index f759b3a..1269aee 100644 --- a/Program.cs +++ b/Program.cs @@ -9,7 +9,7 @@ McpApiOptions mcpOptions = McpApiOptions.FromEnvironment(); WebApplicationBuilder builder = WebApplication.CreateBuilder(args); -builder.WebHost.UseUrls(mcpOptions.Urls); +builder.WebHost.UseUrls(mcpOptions.ListenerUrls); builder.Services .AddBotServices() @@ -20,8 +20,12 @@ WebApplication app = builder.Build(); -app.UseCors(); -app.MapMcpApi(); +if (mcpOptions.Enabled) +{ + app.UseCors(); + app.UseMcpApiSecurity(); + app.MapMcpApi(); +} app.RunStartupMigrations(); await app.StartBotAsync(); diff --git a/README.md b/README.md index ebb45b4..9e54761 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,29 @@ Morpheus features a fully integrated economic system where value is directly tie - **Slots:** Gamble against the server's "Slots Vault" — a central bank that grows with losses and pays out from its own reserves. - **Wealth Transfers:** Securely send money to other users, with a 5% fee (contributed to the UBI pool) to discourage circular loops and encourage a healthy velocity of money. +## MCP server + +Morpheus can expose a standard, read-only Model Context Protocol server at +`/api/mcp`. Its standalone `MCP_API_URLS` listener defaults to +`http://127.0.0.1:5268`, and the endpoint is disabled unless `MCP_API_KEY` is +set. Clients authenticate through the `Authorization` header using the configured +bearer token. + +Browser origins are restricted by `MCP_ALLOWED_ORIGINS` (a comma-separated +list of exact `http` or `https` origins), and requests are rate limited by +`MCP_RATE_LIMIT_PER_MINUTE` (default: 60). The initial tool set exposes only +aggregate guild/activity data, guild-scoped leaderboards, and approved, +non-removed quotes. Pending quotes, removed quotes, logs, balances, and user +directory exports are not exposed. + +The API key authorizes the complete MCP tool surface and should be shared only +with trusted clients. Rate limiting uses the direct client IP address; reverse +proxies should preserve distinct trusted client connections or configure +forwarded headers at the deployment boundary. + +The endpoint uses MCP Streamable HTTP and supports standard methods including +`initialize`, `tools/list`, and `tools/call`. + ## Contributing diff --git a/default.env b/default.env index 42c5557..1e2ee28 100644 --- a/default.env +++ b/default.env @@ -35,3 +35,11 @@ ACTIVITY_SIMILARITY_WINDOW_MINUTES=10 # Max days allowed in activity graphs and leaderboards windows ACTIVITY_GRAPHS_MAX_DAYS=90 + +# Model Context Protocol (MCP) +# Loopback-only by default. The endpoint is disabled until a key is set. +MCP_API_URLS=http://127.0.0.1:5268 +# Clients must send the key as: Authorization: Bearer +MCP_API_KEY= +MCP_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 +MCP_RATE_LIMIT_PER_MINUTE=60 From f83529424a7044bf806d6ff1b40eee2fd9c32201 Mon Sep 17 00:00:00 2001 From: Victor Sandu Date: Sun, 23 Aug 2026 11:07:52 +0300 Subject: [PATCH 3/3] fix: widen MCP quote score totals --- MCP/McpContracts.cs | 4 ++-- MCP/McpService.cs | 10 +++++----- Morpheus.Tests/McpServiceTests.cs | 31 +++++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/MCP/McpContracts.cs b/MCP/McpContracts.cs index f3e1dcd..898ea78 100644 --- a/MCP/McpContracts.cs +++ b/MCP/McpContracts.cs @@ -29,14 +29,14 @@ public sealed record McpQuoteItem( string Author, string Content, DateTime InsertedAtUtc, - int Score); + long Score); public sealed record McpQuoteDetail( int Id, int GuildId, string Content, DateTime InsertedAtUtc, - int TotalScore, + long TotalScore, string Author); public sealed record McpLeaderboardEntry( diff --git a/MCP/McpService.cs b/MCP/McpService.cs index 780f18e..48c2d8f 100644 --- a/MCP/McpService.cs +++ b/MCP/McpService.cs @@ -110,7 +110,7 @@ public async Task GetApprovedQuotesAsync( { "newest" => query.OrderByDescending(quote => quote.InsertDate).ThenByDescending(quote => quote.Id), "oldest" => query.OrderBy(quote => quote.InsertDate).ThenBy(quote => quote.Id), - "score" => query.OrderByDescending(quote => quote.Scores.Sum(score => (int)score.Score)) + "score" => query.OrderByDescending(quote => quote.Scores.Sum(score => (long)score.Score)) .ThenByDescending(quote => quote.Id), _ => throw new ArgumentException("Sort must be newest, oldest, or score.", nameof(sort)) }; @@ -124,11 +124,11 @@ public async Task GetApprovedQuotesAsync( return new McpQuotePage(effectivePage, totalPages, total, []); List quoteIds = [.. quotes.Select(quote => quote.Id)]; - Dictionary scoreMap = await dbContext.QuoteScores + Dictionary scoreMap = await dbContext.QuoteScores .AsNoTracking() .Where(score => quoteIds.Contains(score.QuoteId)) .GroupBy(score => score.QuoteId) - .Select(group => new { QuoteId = group.Key, Score = group.Sum(score => score.Score) }) + .Select(group => new { QuoteId = group.Key, Score = group.Sum(score => (long)score.Score) }) .ToDictionaryAsync(group => group.QuoteId, group => group.Score, ct); List userIds = [.. quotes.Select(quote => quote.UserId).Distinct()]; @@ -164,10 +164,10 @@ public async Task GetApprovedQuotesAsync( if (quote is null) return null; - int totalScore = await dbContext.QuoteScores + long totalScore = await dbContext.QuoteScores .AsNoTracking() .Where(score => score.QuoteId == quote.Id) - .SumAsync(score => (int?)score.Score, ct) ?? 0; + .SumAsync(score => (long?)score.Score, ct) ?? 0; string author = await dbContext.Users .AsNoTracking() diff --git a/Morpheus.Tests/McpServiceTests.cs b/Morpheus.Tests/McpServiceTests.cs index ab0b88a..695b18c 100644 --- a/Morpheus.Tests/McpServiceTests.cs +++ b/Morpheus.Tests/McpServiceTests.cs @@ -76,6 +76,37 @@ public async Task GetApprovedQuoteAsync_ReturnsNullForPendingOrRemovedQuote() Assert.Null(await service.GetApprovedQuoteAsync(removed.Id)); } + [Fact] + public async Task ApprovedQuoteScores_SupportTotalsLargerThanIntMaxValue() + { + await using SqliteTestDb testDb = await CreateSqliteDbAsync(); + (Guild guild, User user) = await SeedBaseAsync(testDb.Db); + User secondUser = new() { DiscordId = 789, Username = "SecondUser" }; + testDb.Db.Users.Add(secondUser); + await testDb.Db.SaveChangesAsync(); + + Quote quote = new() + { + GuildId = guild.Id, + UserId = user.Id, + Content = "high score", + Approved = true + }; + testDb.Db.Quotes.Add(quote); + await testDb.Db.SaveChangesAsync(); + testDb.Db.QuoteScores.AddRange( + new QuoteScore { QuoteId = quote.Id, UserId = user.Id, Score = int.MaxValue }, + new QuoteScore { QuoteId = quote.Id, UserId = secondUser.Id, Score = 1 }); + await testDb.Db.SaveChangesAsync(); + + McpService service = new(testDb.Db); + McpQuotePage page = await service.GetApprovedQuotesAsync(sort: "score"); + McpQuoteDetail? detail = await service.GetApprovedQuoteAsync(quote.Id); + + Assert.Equal((long)int.MaxValue + 1, Assert.Single(page.Items).Score); + Assert.Equal((long)int.MaxValue + 1, detail?.TotalScore); + } + [Fact] public async Task GetGuildInfoAsync_ReturnsOnlyAggregateGuildData() {