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 new file mode 100644 index 0000000..dd3617d --- /dev/null +++ b/MCP/McpApiExtensions.cs @@ -0,0 +1,94 @@ +using System.Threading.RateLimiting; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.DependencyInjection; + +namespace Morpheus.MCP; + +public static class McpApiExtensions +{ + private const string CorsPolicyName = "McpCors"; + private const string RateLimitPolicyName = "McpRateLimit"; + + public static IServiceCollection AddMcpApi( + this IServiceCollection services, + McpApiOptions options) + { + options.Validate(); + services.AddSingleton(options); + + if (!options.Enabled) + return services; + + services.AddScoped(); + + services + .AddMcpServer() + .WithHttpTransport(transport => transport.Stateless = true) + .WithTools(); + + services.AddCors(corsOptions => + { + corsOptions.AddPolicy(CorsPolicyName, policy => + { + if (options.AllowedOrigins.Length > 0) + { + 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"); + } + }); + }); + + services.AddRateLimiter(rateLimitOptions => + { + 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 + })); + }); + + return services; + } + + public static WebApplication UseMcpApiSecurity(this WebApplication app) + { + McpApiOptions options = app.Services.GetRequiredService(); + if (options.Enabled) + { + app.UseRateLimiter(); + app.UseMiddleware(); + } + + return app; + } + + public static WebApplication MapMcpApi(this WebApplication app) + { + McpApiOptions options = app.Services.GetRequiredService(); + if (!options.Enabled) + return app; + + app.MapMcp("/api/mcp") + .RequireCors(CorsPolicyName) + .RequireRateLimiting(RateLimitPolicyName); + + return app; + } +} diff --git a/MCP/McpApiOptions.cs b/MCP/McpApiOptions.cs new file mode 100644 index 0000000..9608846 --- /dev/null +++ b/MCP/McpApiOptions.cs @@ -0,0 +1,86 @@ +using Morpheus.Utilities; + +namespace Morpheus.MCP; + +/// +/// 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[] 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 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('/'); + } +} diff --git a/MCP/McpContracts.cs b/MCP/McpContracts.cs new file mode 100644 index 0000000..898ea78 --- /dev/null +++ b/MCP/McpContracts.cs @@ -0,0 +1,46 @@ +namespace Morpheus.MCP; + +public sealed record McpGuildInfo( + int Id, + string Name, + int TrackedUsers, + long Messages, + long Xp, + int ApprovedQuotes); + +public sealed record McpActivityOverview( + long TotalMessages, + long TotalXp, + int ActiveUsersLast30Days, + long MessagesLast30Days, + long XpLast30Days, + int TotalServers, + int TotalKnownUsers); + +public sealed record McpQuotePage( + int Page, + int TotalPages, + int Total, + IReadOnlyList Items); + +public sealed record McpQuoteItem( + int Id, + int GuildId, + string Author, + string Content, + DateTime InsertedAtUtc, + long Score); + +public sealed record McpQuoteDetail( + int Id, + int GuildId, + string Content, + DateTime InsertedAtUtc, + long TotalScore, + string Author); + +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 new file mode 100644 index 0000000..48c2d8f --- /dev/null +++ b/MCP/McpService.cs @@ -0,0 +1,246 @@ +using Microsoft.EntityFrameworkCore; +using Morpheus.Database; +using Morpheus.Database.Models; + +namespace Morpheus.MCP; + +/// +/// Read-only, deliberately limited data surface exposed through MCP. +/// +public sealed class McpService(DB dbContext) +{ + private const int QuotePageSize = 10; + + public async Task GetGuildInfoAsync( + int? guildId, + ulong? discordId, + CancellationToken ct = default) + { + IQueryable query = dbContext.Guilds.AsNoTracking(); + + if (guildId is > 0) + query = query.Where(g => g.Id == guildId.Value); + else if (discordId is > 0) + query = query.Where(g => g.DiscordId == discordId.Value); + else + throw new ArgumentException("Provide a positive guildId or discordId."); + + Guild? guild = await query.FirstOrDefaultAsync(ct); + if (guild is null) + return null; + + var levels = await dbContext.UserLevels + .AsNoTracking() + .Where(level => level.GuildId == guild.Id) + .GroupBy(_ => 1) + .Select(group => new + { + 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(quote => + quote.GuildId == guild.Id && quote.Approved && !quote.Removed, + ct); + + return new McpGuildInfo( + guild.Id, + guild.Name, + levels?.Users ?? 0, + levels?.Messages ?? 0, + levels?.Xp ?? 0, + approvedQuotes); + } + + public async Task GetActivityOverviewAsync(CancellationToken ct = default) + { + DateTime last30Days = DateTime.UtcNow.AddDays(-30); + + var levelTotals = await dbContext.UserLevels + .AsNoTracking() + .GroupBy(_ => 1) + .Select(group => new + { + Messages = group.Sum(level => (long)level.UserMessageCount), + Xp = group.Sum(level => (long)level.TotalXp) + }) + .FirstOrDefaultAsync(ct); + + IQueryable recentActivity = dbContext.UserActivity + .AsNoTracking() + .Where(activity => activity.InsertDate >= last30Days); + + return new McpActivityOverview( + 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)); + } + + public async Task GetApprovedQuotesAsync( + int page = 1, + string sort = "newest", + int? guildId = null, + CancellationToken ct = default) + { + 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."); + + IQueryable query = dbContext.Quotes + .AsNoTracking() + .Where(quote => quote.Approved && !quote.Removed); + + if (guildId.HasValue) + query = query.Where(quote => quote.GuildId == guildId.Value); + + int total = await query.CountAsync(ct); + int totalPages = Math.Max(1, (int)Math.Ceiling(total / (double)QuotePageSize)); + int effectivePage = Math.Min(page, totalPages); + + query = sort.ToLowerInvariant() switch + { + "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 => (long)score.Score)) + .ThenByDescending(quote => quote.Id), + _ => throw new ArgumentException("Sort must be newest, oldest, or score.", nameof(sort)) + }; + + List quotes = await query + .Skip((effectivePage - 1) * QuotePageSize) + .Take(QuotePageSize) + .ToListAsync(ct); + + if (quotes.Count == 0) + return new McpQuotePage(effectivePage, totalPages, total, []); + + List quoteIds = [.. quotes.Select(quote => quote.Id)]; + 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 => (long)score.Score) }) + .ToDictionaryAsync(group => group.QuoteId, group => group.Score, ct); + + List userIds = [.. quotes.Select(quote => quote.UserId).Distinct()]; + Dictionary userMap = await dbContext.Users + .AsNoTracking() + .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); + } + + 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(candidate => + candidate.Id == quoteId && candidate.Approved && !candidate.Removed, + ct); + + if (quote is null) + return null; + + long totalScore = await dbContext.QuoteScores + .AsNoTracking() + .Where(score => score.QuoteId == quote.Id) + .SumAsync(score => (long?)score.Score, ct) ?? 0; + + string author = await dbContext.Users + .AsNoTracking() + .Where(user => user.Id == quote.UserId) + .Select(user => user.Username) + .FirstOrDefaultAsync(ct) ?? "Unknown"; + + return new McpQuoteDetail( + quote.Id, + quote.GuildId, + quote.Content ?? string.Empty, + quote.InsertDate, + totalScore, + author); + } + + public async Task> GetLeaderboardAsync( + string metric, + int guildId, + int days = 30, + int limit = 10, + CancellationToken ct = default) + { + 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(activity => + activity.GuildId == guildId && activity.InsertDate >= since); + + var values = metric.ToLowerInvariant() switch + { + "messages" => await activityQuery + .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), + "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), + _ => throw new ArgumentException("Metric must be xp or messages.", nameof(metric)) + }; + + if (values.Count == 0) + return []; + + List userIds = [.. values.Select(item => item.UserId)]; + Dictionary users = await dbContext.Users + .AsNoTracking() + .Where(user => userIds.Contains(user.Id)) + .ToDictionaryAsync(user => user.Id, user => user.Username, ct); + + Dictionary levels = await dbContext.UserLevels + .AsNoTracking() + .Where(level => level.GuildId == guildId && userIds.Contains(level.UserId)) + .ToDictionaryAsync(level => level.UserId, level => (int?)level.Level, ct); + + return [.. values.Select((item, index) => new McpLeaderboardEntry( + index + 1, + users.GetValueOrDefault(item.UserId, "Unknown"), + item.Value, + levels.GetValueOrDefault(item.UserId)))]; + } +} 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 new file mode 100644 index 0000000..695b18c --- /dev/null +++ b/Morpheus.Tests/McpServiceTests.cs @@ -0,0 +1,210 @@ +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 GetApprovedQuotesAsync_NeverReturnsPendingOrRemovedQuotes() + { + await using SqliteTestDb testDb = await CreateSqliteDbAsync(); + (Guild guild, User user) = await SeedBaseAsync(testDb.Db); + + 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(); + + McpQuotePage result = await new McpService(testDb.Db).GetApprovedQuotesAsync(); + + McpQuoteItem item = Assert.Single(result.Items); + Assert.Equal("approved", item.Content); + Assert.Equal(1, result.Total); + } + + [Fact] + public async Task GetApprovedQuoteAsync_ReturnsNullForPendingOrRemovedQuote() + { + await using SqliteTestDb testDb = await CreateSqliteDbAsync(); + (Guild guild, User user) = await SeedBaseAsync(testDb.Db); + + Quote pending = new() + { + GuildId = guild.Id, + UserId = user.Id, + Content = "pending", + Approved = false + }; + Quote removed = new() + { + GuildId = guild.Id, + UserId = user.Id, + Content = "removed", + Approved = true, + Removed = true + }; + testDb.Db.Quotes.AddRange(pending, removed); + await testDb.Db.SaveChangesAsync(); + + McpService service = new(testDb.Db); + + Assert.Null(await service.GetApprovedQuoteAsync(pending.Id)); + 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() + { + 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 + }); + await testDb.Db.SaveChangesAsync(); + + McpGuildInfo? result = await new McpService(testDb.Db) + .GetGuildInfoAsync(guild.Id, null); + + 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 GetLeaderboardAsync_IsGuildScopedAndValidatesBounds() + { + await using SqliteTestDb testDb = await CreateSqliteDbAsync(); + (Guild guild, User user) = await SeedBaseAsync(testDb.Db); + Guild otherGuild = new() { DiscordId = 999, Name = "Other" }; + testDb.Db.Guilds.Add(otherGuild); + await testDb.Db.SaveChangesAsync(); + + testDb.Db.UserActivity.AddRange( + CreateActivity(guild.Id, user.Id, 10), + CreateActivity(otherGuild.Id, user.Id, 1000)); + await testDb.Db.SaveChangesAsync(); + + McpService service = new(testDb.Db); + IReadOnlyList result = await service.GetLeaderboardAsync( + "xp", guild.Id, 30, 10); + + McpLeaderboardEntry entry = Assert.Single(result); + Assert.Equal(10, entry.Value); + await Assert.ThrowsAsync( + () => service.GetLeaderboardAsync("xp", guild.Id, 366, 10)); + } + + private static UserActivity CreateActivity(int guildId, int userId, int xp) => new() + { + 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 + { + 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(); + DB db = new(new DbContextOptionsBuilder().UseSqlite(connection).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!" + }; + User user = new() + { + DiscordId = 456, + Username = "TestUser", + Balance = 1000m + }; + db.Guilds.Add(guild); + db.Users.Add(user); + await db.SaveChangesAsync(); + return (guild, user); + } +} diff --git a/Morpheus.Tests/Morpheus.Tests.csproj b/Morpheus.Tests/Morpheus.Tests.csproj index 5a42044..22d3c6a 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 7227179..1269aee 100644 --- a/Program.cs +++ b/Program.cs @@ -1,20 +1,32 @@ -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.ListenerUrls); + +builder.Services + .AddBotServices() + .AddBotJobs() + .AddBotHandlers() + .AddBotDatabase() + .AddMcpApi(mcpOptions); + +WebApplication app = builder.Build(); + +if (mcpOptions.Enabled) +{ + app.UseCors(); + app.UseMcpApiSecurity(); + app.MapMcpApi(); +} + +app.RunStartupMigrations(); +await app.StartBotAsync(); +await app.RunAsync(); 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