Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
94 changes: 94 additions & 0 deletions MCP/McpApiExtensions.cs
Original file line number Diff line number Diff line change
@@ -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<McpService>();

services
.AddMcpServer()
.WithHttpTransport(transport => transport.Stateless = true)
.WithTools<McpTools>();

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<McpApiOptions>();
if (options.Enabled)
{
app.UseRateLimiter();
app.UseMiddleware<McpSecurityMiddleware>();
}

return app;
}

public static WebApplication MapMcpApi(this WebApplication app)
{
McpApiOptions options = app.Services.GetRequiredService<McpApiOptions>();
if (!options.Enabled)
return app;

app.MapMcp("/api/mcp")
.RequireCors(CorsPolicyName)
.RequireRateLimiting(RateLimitPolicyName);

return app;
}
}
86 changes: 86 additions & 0 deletions MCP/McpApiOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
using Morpheus.Utilities;

namespace Morpheus.MCP;

/// <summary>
/// Security and rate-limit configuration for the MCP endpoint.
/// The MCP server is disabled unless an API key is configured.
/// </summary>
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('/');
}
}
46 changes: 46 additions & 0 deletions MCP/McpContracts.cs
Original file line number Diff line number Diff line change
@@ -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<McpQuoteItem> 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);
57 changes: 57 additions & 0 deletions MCP/McpSecurityMiddleware.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Primitives;

namespace Morpheus.MCP;

/// <summary>
/// Enforces exact Origin validation and bearer-key authorization for every MCP request.
/// </summary>
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);
}
}
Loading