Skip to content

Commit cc8ea31

Browse files
committed
feat: v3.17.0 — MCP rate limiting (warn + McpOptions.RateLimiterPolicy)
A routine's rate_limiter annotation does not carry to MCP: tools/call invokes the routine directly, bypassing per-route middleware. Two changes: - Build-time warning when an mcp-annotated routine also has rate_limiter, pointing operators at McpOptions.RateLimiterPolicy. - McpOptions.RateLimiterPolicy: name of a host-registered ASP.NET rate-limiter policy applied to the whole /mcp endpoint. When set, /mcp (and the PRM path) are served as mapped endpoints so RequireRateLimiting can attach; both GET and POST are mapped so HandleAsync keeps emitting the in-handler 405 (behavior identical to the prior middleware). Falls back to middleware on hosts without endpoint routing, logging that the policy cannot apply there. Wired through App.cs + the 4-file config set (appsettings.json, ConfigTemplate, ConfigDefaults, ConfigSchemaGenerator). New McpRateLimiterTests + fixture prove a 1-permit policy throttles the endpoint (1st 200, 2nd 429). Full suite green, AOT-clean.
1 parent 8507b4c commit cc8ea31

12 files changed

Lines changed: 182 additions & 1 deletion

File tree

NpgsqlRestClient/App.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,7 @@ public List<IEndpointCreateHandler> CreateCodeGenHandlers(string connectionStrin
589589
ServerVersion = _config.GetConfigStr("ServerVersion", mcpCfg),
590590
Instructions = _config.GetConfigStr("Instructions", mcpCfg),
591591
ToolDescriptionSuffix = _config.GetConfigStr("ToolDescriptionSuffix", mcpCfg),
592+
RateLimiterPolicy = _config.GetConfigStr("RateLimiterPolicy", mcpCfg),
592593
AllowedOrigins = [.. _config.GetConfigEnumerable("AllowedOrigins", mcpCfg) ?? []],
593594
Authorization = new McpAuthorizationOptions
594595
{

NpgsqlRestClient/ConfigDefaults.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -980,6 +980,7 @@ private static JsonObject GetMcpOptionsDefaults()
980980
["ServerVersion"] = "1.0.0",
981981
["Instructions"] = null,
982982
["ToolDescriptionSuffix"] = null,
983+
["RateLimiterPolicy"] = null,
983984
["AllowedOrigins"] = new JsonArray(),
984985
["Authorization"] = new JsonObject
985986
{

NpgsqlRestClient/ConfigSchemaGenerator.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,7 @@ public static partial class ConfigSchemaGenerator
443443
["NpgsqlRest:McpOptions:ServerVersion"] = "serverInfo.version reported in the MCP initialize handshake.",
444444
["NpgsqlRest:McpOptions:Instructions"] = "Optional server-level instructions returned in the MCP initialize handshake (high-level guidance for the agent). Null = omitted.",
445445
["NpgsqlRest:McpOptions:ToolDescriptionSuffix"] = "Optional text appended to every MCP tool description. Null = no-op. Use for short shared context the agent should always see (e.g. \"Read-only Acme CRM.\"); for longer server-wide guidance prefer Instructions.",
446+
["NpgsqlRest:McpOptions:RateLimiterPolicy"] = "Name of an ASP.NET rate-limiter policy applied to the whole /mcp endpoint. Null = no limiting. A routine's own rate_limiter annotation does not carry to MCP (tools/call bypasses route middleware), so this is how MCP traffic is throttled. The named policy must be registered on the host (AddRateLimiter + UseRateLimiter); an unregistered name surfaces as the framework's error when a request hits the endpoint.",
446447
["NpgsqlRest:McpOptions:AllowedOrigins"] = "Allowed values of the HTTP Origin header (DNS-rebinding protection for the Streamable HTTP transport). A request whose Origin is present but matches neither this list nor the server's own origin is rejected with 403. Requests without an Origin header (e.g. server-to-server) are allowed. Empty = only same-origin browser requests pass.",
447448
["NpgsqlRest:McpOptions:Authorization"] = "OAuth 2.1 Resource Server settings. Token validation reuses the host's bearer authentication; these keys configure the transport gate and the Protected Resource Metadata document (RFC 9728). NpgsqlRest is not an Authorization Server — point AuthorizationServers at an external IdP.",
448449
["NpgsqlRest:McpOptions:Authorization:RequireAuthorization"] = "When true, every MCP request requires an authenticated principal. When false (default), anonymous is allowed and a tool's own `authorize` annotation still gates it per call.",

NpgsqlRestClient/ConfigTemplate.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2590,6 +2590,10 @@ public static partial class ConfigSchemaGenerator
25902590
//
25912591
"ToolDescriptionSuffix": null,
25922592
//
2593+
// Name of an ASP.NET rate-limiter policy applied to the whole /mcp endpoint. Null = no limiting. A routine's own rate_limiter annotation does not carry to MCP (tools/call bypasses route middleware), so this is how MCP traffic is throttled. The named policy must be registered on the host (AddRateLimiter + UseRateLimiter); an unregistered name surfaces as the framework's error when a request hits the endpoint.
2594+
//
2595+
"RateLimiterPolicy": null,
2596+
//
25932597
// Allowed values of the HTTP Origin header (DNS-rebinding protection for the Streamable HTTP transport). A request whose Origin is present but matches neither this list nor the server's own origin is rejected with 403. Requests without an Origin header (e.g. server-to-server) are allowed. Empty = only same-origin browser requests pass.
25942598
//
25952599
"AllowedOrigins": [],

NpgsqlRestClient/appsettings.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2581,6 +2581,10 @@
25812581
//
25822582
"ToolDescriptionSuffix": null,
25832583
//
2584+
// Name of an ASP.NET rate-limiter policy applied to the whole /mcp endpoint. Null = no limiting. A routine's own rate_limiter annotation does not carry to MCP (tools/call bypasses route middleware), so this is how MCP traffic is throttled. The named policy must be registered on the host (AddRateLimiter + UseRateLimiter); an unregistered name surfaces as the framework's error when a request hits the endpoint.
2585+
//
2586+
"RateLimiterPolicy": null,
2587+
//
25842588
// Allowed values of the HTTP Origin header (DNS-rebinding protection for the Streamable HTTP transport). A request whose Origin is present but matches neither this list nor the server's own origin is rejected with 403. Requests without an Origin header (e.g. server-to-server) are allowed. Empty = only same-origin browser requests pass.
25852589
//
25862590
"AllowedOrigins": [],

NpgsqlRestTests/McpTests/McpFeatureWarnTests.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ comment on function mcp_warn.tool_login() is '
2020
HTTP POST
2121
login
2222
@mcp Sign in.';
23+
24+
-- @mcp on a routine with a rate_limiter: route-level rate limiting does not carry to MCP.
25+
create function mcp_warn.tool_rate_limited() returns text language sql as 'select ''x''';
26+
comment on function mcp_warn.tool_rate_limited() is '
27+
HTTP GET
28+
@mcp Rate-limited routine.
29+
rate_limiter mcp_warn_policy';
2330
");
2431
}
2532
}
@@ -46,6 +53,15 @@ public void Mcp_annotation_appears_in_the_endpoint_annotations_summary()
4653
summary!.Message.Should().Contain("Sign in."); // tool_login's `@mcp Sign in.` annotation
4754
}
4855

56+
[Fact]
57+
public void A_rate_limiter_annotation_on_an_mcp_routine_logs_a_warning()
58+
{
59+
var warning = test.StartupLogs.FirstOrDefault(l =>
60+
l.Message.Contains("route-level rate limiting does not apply to MCP") && l.Message.Contains("tool_rate_limited"));
61+
warning.Should().NotBeNull("the plugin should warn that a routine's rate_limiter doesn't carry to MCP");
62+
warning!.Level.Should().Be(LogLevel.Warning);
63+
}
64+
4965
[Fact]
5066
public void The_exposed_tool_catalog_is_logged_at_startup()
5167
{
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
using NpgsqlRestTests.Setup;
2+
3+
namespace NpgsqlRestTests;
4+
5+
/// <summary>
6+
/// Verifies <see cref="NpgsqlRest.Mcp.McpOptions.RateLimiterPolicy"/> throttles the whole <c>/mcp</c>
7+
/// endpoint. The fixture registers a fixed-window policy of one permit per long window, so the second
8+
/// request is rejected by ASP.NET's rate limiter (429) before the JSON-RPC handler runs.
9+
/// </summary>
10+
[Collection("McpRateLimiterFixture")]
11+
public class McpRateLimiterTests(McpRateLimiterTestFixture test)
12+
{
13+
private async Task<HttpResponseMessage> InitializeAsync()
14+
{
15+
using var content = new StringContent(
16+
"""{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}""", Encoding.UTF8, "application/json");
17+
return await test.Client.PostAsync("/mcp", content);
18+
}
19+
20+
[Fact]
21+
public async Task RateLimiterPolicy_throttles_the_mcp_endpoint()
22+
{
23+
using var first = await InitializeAsync();
24+
first.StatusCode.Should().Be(HttpStatusCode.OK); // first request consumes the single permit
25+
26+
using var second = await InitializeAsync();
27+
second.StatusCode.Should().Be(HttpStatusCode.TooManyRequests); // second is rejected by the rate limiter
28+
}
29+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
using System.Threading.RateLimiting;
2+
using Microsoft.AspNetCore.Builder;
3+
using Microsoft.AspNetCore.RateLimiting;
4+
using Microsoft.AspNetCore.Hosting;
5+
using Microsoft.AspNetCore.Http;
6+
using Microsoft.Extensions.DependencyInjection;
7+
using Microsoft.Extensions.Logging;
8+
using NpgsqlRest.Mcp;
9+
10+
namespace NpgsqlRestTests.Setup;
11+
12+
[CollectionDefinition("McpRateLimiterFixture")]
13+
public class McpRateLimiterFixtureCollection : ICollectionFixture<McpRateLimiterTestFixture> { }
14+
15+
/// <summary>
16+
/// Fixture proving <see cref="McpOptions.RateLimiterPolicy"/> is applied to the whole <c>/mcp</c> endpoint.
17+
/// The host registers a fixed-window policy that allows a single request per (long) window; the second
18+
/// request to <c>/mcp</c> must be rejected by the rate limiter with 429.
19+
/// </summary>
20+
public class McpRateLimiterTestFixture : IDisposable
21+
{
22+
public const string PolicyName = "mcp_test_policy";
23+
24+
private readonly WebApplication _app;
25+
private readonly HttpClient _client;
26+
27+
public HttpClient Client => _client;
28+
29+
public McpRateLimiterTestFixture()
30+
{
31+
Database.Create();
32+
var connectionString = Database.CreateAdditional("mcp_rate_limiter_test");
33+
34+
var builder = WebApplication.CreateBuilder();
35+
builder.WebHost.UseUrls("http://127.0.0.1:0");
36+
builder.Logging.ClearProviders();
37+
38+
// One permit per (long) window, no queue → the second request inside the window is rejected.
39+
builder.Services.AddRateLimiter(o =>
40+
{
41+
o.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
42+
o.AddFixedWindowLimiter(PolicyName, opt =>
43+
{
44+
opt.PermitLimit = 1;
45+
opt.Window = TimeSpan.FromMinutes(10);
46+
opt.QueueLimit = 0;
47+
});
48+
});
49+
50+
_app = builder.Build();
51+
_app.UseRateLimiter();
52+
53+
_app.UseNpgsqlRest(new NpgsqlRestOptions(connectionString)
54+
{
55+
IncludeSchemas = ["mcp"],
56+
CommentsMode = CommentsMode.OnlyAnnotated,
57+
EndpointCreateHandlers =
58+
[
59+
new Mcp(new McpOptions
60+
{
61+
Enabled = true,
62+
RateLimiterPolicy = PolicyName,
63+
})
64+
]
65+
});
66+
67+
_app.StartAsync().GetAwaiter().GetResult();
68+
_client = new HttpClient { BaseAddress = new Uri(_app.Urls.First()) };
69+
_client.Timeout = TimeSpan.FromHours(1);
70+
}
71+
72+
#pragma warning disable CA1816 // Dispose methods should call SuppressFinalize
73+
public void Dispose()
74+
#pragma warning restore CA1816 // Dispose methods should call SuppressFinalize
75+
{
76+
_client.Dispose();
77+
_app.StopAsync().GetAwaiter().GetResult();
78+
_app.DisposeAsync().GetAwaiter().GetResult();
79+
}
80+
}

changelog/v3.17.0.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,13 @@ NpgsqlRest can now expose opted-in PostgreSQL routines as **MCP tools**, so an A
3434
- **Audience binding (RFC 8707):** with a canonical `Audience` configured, a token must carry it (`aud` claim) or it is rejected with `401`.
3535
- **Per-tool authorization:** the routine's `authorize`/role check runs on `tools/call``401` if called anonymously, `403` `insufficient_scope` if the role is missing (RFC 6750 §3.1; challenges include `scope` and `resource_metadata`). No authorization logic is duplicated in the plugin — it reuses core's check.
3636

37-
**Configuration**`NpgsqlRest:McpOptions`, disabled by default, surfaced in `--config`, `--config-schema`, and the JSON schema: `Enabled`, `UrlPath` (`/mcp`), `ServerName` (null → database name → `"NpgsqlRest"`), `ServerVersion` (`"1.0.0"`), `Instructions`, `ToolDescriptionSuffix`, `AllowedOrigins`, and the `Authorization` object (`RequireAuthorization`, `AuthorizationServers`, `ScopesSupported`, `Audience`, `ProtectedResourceMetadataPath`, `FilterToolsByRole`).
37+
**Configuration**`NpgsqlRest:McpOptions`, disabled by default, surfaced in `--config`, `--config-schema`, and the JSON schema: `Enabled`, `UrlPath` (`/mcp`), `ServerName` (null → database name → `"NpgsqlRest"`), `ServerVersion` (`"1.0.0"`), `Instructions`, `ToolDescriptionSuffix`, `RateLimiterPolicy`, `AllowedOrigins`, and the `Authorization` object (`RequireAuthorization`, `AuthorizationServers`, `ScopesSupported`, `Audience`, `ProtectedResourceMetadataPath`, `FilterToolsByRole`).
3838

3939
**Diagnostics & current limitations.**
4040

4141
- Enabling MCP does **not** enable authentication — it is configured separately (the host's `Auth` section). If `RequireAuthorization` is on but no authentication scheme is registered, a startup warning is logged.
4242
- A routine annotated `mcp` that also uses a feature with no MCP equivalent (`login`, `logout`, basic auth, `upload`, SSE) logs a build-time warning.
43+
- A routine's `rate_limiter` annotation does not carry to MCP (`tools/call` bypasses route middleware); pairing it with `mcp` logs a build-time warning. Use `McpOptions:RateLimiterPolicy` (a host-registered ASP.NET rate-limiter policy) to throttle the whole `/mcp` endpoint.
4344
- `tools/list` lists every opted-in tool by default (keeping them discoverable); set `Authorization.FilterToolsByRole` to hide tools the caller can't run. Authorization is enforced on `tools/call` regardless.
4445
- The JSON-RPC layer is hand-rolled over `System.Text.Json.Nodes` with relaxed escaping (conventional `application/json` output) — no reflection-based serialization, AOT-safe (verified via `dotnet publish -p:PublishAot=true`).
4546

plugins/NpgsqlRest.Mcp/Mcp.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,15 @@ private static void WarnIfNonApplicableFeature(RoutineEndpoint endpoint, string
123123
"MCP tool '{Name}' ({Schema}.{Routine}) is annotated `mcp` but uses a feature that does not apply to MCP tools ({Feature}). The tool is exposed but may not behave as expected over JSON-RPC.",
124124
toolName, endpoint.Routine.Schema, endpoint.Routine.Name, feature);
125125
}
126+
127+
// A routine's `rate_limiter` policy applies to its HTTP route, not to MCP — tools/call invokes the
128+
// routine directly, bypassing route middleware. Use McpOptions.RateLimiterPolicy for the /mcp endpoint.
129+
if (endpoint.RateLimiterPolicy is not null)
130+
{
131+
Logger?.LogWarning(
132+
"MCP tool '{Name}' ({Schema}.{Routine}) has a `rate_limiter` annotation, but route-level rate limiting does not apply to MCP calls. Set McpOptions.RateLimiterPolicy to throttle the /mcp endpoint.",
133+
toolName, endpoint.Routine.Schema, endpoint.Routine.Name);
134+
}
126135
}
127136

128137
private JsonObject BuildTool(RoutineEndpoint endpoint, McpToolInfo info)

0 commit comments

Comments
 (0)