Add hourly MCP Server rate limits per plan
What does this MR do and why?
Adds an hourly limit to the per-plan MCP Server rate limits on GitLab.com: 3,600 requests an hour for Free, and 5,000 for Premium and Ultimate. Today MCP Server requests are limited only per minute. The new rules ship behind six per-plan gitlab_com_derisk flags, all off by default, so each plan can log before it enforces.
🤖 Rules, flags and decisions
New rules in MCP_RULES, each with a _log twin, counted per user:
| Rule | Free | Premium | Ultimate | Period |
|---|---|---|---|---|
sustained_mcp_traffic_per_user_<plan>_plan (new) |
3,600 | 5,000 | 5,000 | 1 hour |
burst_mcp_traffic_per_user_<plan>_plan (unchanged) |
60 | 600 | 600 | 1 minute |
| Flag | Effect |
|---|---|
rate_limiter_mcp_sustained_limits_<plan>_info |
The _log twin counts that plan's MCP traffic, never rejects |
rate_limiter_mcp_sustained_limits_<plan>_enforce |
Only with _info on: the :limit rule rejects with 429 |
- New flags, not the existing
rate_limiter_mcp_limits_<plan>_*: those gate only the per-minute rules and are already on in gprd, so reusing them would enforce the hourly rules on deploy. - Two flags per plan, following the Tier-Aware Request Throttles design, so each plan rolls out on its own. The rule names follow the existing
sustained_/burst_convention. - Within each plan the sustained pair comes before the burst pair, as in
PLAN_RULES.MCP_RULESstays ahead ofPLAN_RULES, so a request the hourly MCP rule rejects isn't counted by the general plan limits. One it allows is counted by both. - A request the per-minute rule rejects still counts toward the hourly limit, the same as with the general plan rules, because Labkit counts every matching rule up to the one that rejects.
- All six flags are in
FlagPolicy::FLAGSandany_flag_enabled?, so they're stubbed off in unrelated specs and turning on only these flags still runs the middleware. RateLimit-Limitis reported per minute (Gitlab::RackAttack::RequestThrottleData#rounded_limit), so a rejection by the 5,000 an hour rule reports84.RateLimit-Namenames the rule.- No changelog entry: the flags are off by default.
References
- #634981
- Per-minute MCP rate limits: !257085 (merged)
Screenshots or screen recordings
No UI changes.
How to set up and validate locally
- Put the GDK in SaaS mode (
GITLAB_SIMULATE_SAAS=1) and restartrails-web. Without itPlanRules.for_limiter('rack_request')returns no MCP rule. - Pick a Free user who belongs to a group with the MCP server enabled. On SaaS,
/api/v4/mcpreturns403without one, after the rate limiter has already counted the request.user = User.find_by_username('<user>') Gitlab::RateLimit::TierResolver.tier_for('user', user.id) # => "free" user.any_group_with_mcp_server_enabled? # => true - Mint an OAuth access token with the
mcpscope for that user, as an MCP client would. - Turn on the Free observe flag and seed the
_logcounter at the limit. Wait about a minute after each flag change, since Puma workers pick it up at different times.Feature.enable(:rate_limiter_mcp_sustained_limits_free_info) key = "labkit:rl:{rack_request:sustained_mcp_traffic_per_user_free_plan_log:requester_type:user:requester_id:#{user.id}}" Gitlab::Redis::RateLimiting.with { |r| r.set(key, 3600, ex: 3600) } - Send
tools/listtoPOST /api/v4/mcpwithAuthorization: Bearer <token>. Expect200, and the key reads3601. - Turn on enforcement and seed the
:limitrule's key, which is the same key without_log:Feature.enable(:rate_limiter_mcp_sustained_limits_free_enforce) Gitlab::Redis::RateLimiting.with { |r| r.set(key.sub('_plan_log:', '_plan:'), 3600, ex: 3600) } - Send the same request. Expect
429withRateLimit-Name: sustained_mcp_traffic_per_user_free_plan. - Run
Feature.disable(:rate_limiter_mcp_sustained_limits_free_enforce)and send it again. Expect200, which checks the rollback.
🤖 What I observed
GDK on this branch, SaaS mode, a Free user in a group with the MCP server enabled, an OAuth token with the mcp scope. The existing rate_limiter_mcp_limits_free_* flags were on.
| Step | Response | RateLimit-Name |
Counters after the request |
|---|---|---|---|
| Both sustained flags off | 200 |
burst_mcp_traffic_per_user_free_plan |
No sustained keys, burst 1 |
_info on, _log seeded at 3600 |
200 |
burst_mcp_traffic_per_user_free_plan |
_log 3601, no :limit key |
_info and _enforce on, :limit seeded at 3600 |
429, RateLimit-Limit: 60, Retry-After: 63 |
sustained_mcp_traffic_per_user_free_plan |
:limit 3601, burst not counted |
_enforce off again |
200 |
burst_mcp_traffic_per_user_free_plan |
_log 3602 |
RateLimit-Limit: 60 is 3,600 an hour reported per minute. The burst rule isn't counted on the rejected request because the hourly rule comes first and rejects it.
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.