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_RULES stays ahead of PLAN_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::FLAGS and any_flag_enabled?, so they're stubbed off in unrelated specs and turning on only these flags still runs the middleware.
  • RateLimit-Limit is reported per minute (Gitlab::RackAttack::RequestThrottleData#rounded_limit), so a rejection by the 5,000 an hour rule reports 84. RateLimit-Name names the rule.
  • No changelog entry: the flags are off by default.

References

Screenshots or screen recordings

No UI changes.

How to set up and validate locally

  1. Put the GDK in SaaS mode (GITLAB_SIMULATE_SAAS=1) and restart rails-web. Without it PlanRules.for_limiter('rack_request') returns no MCP rule.
  2. Pick a Free user who belongs to a group with the MCP server enabled. On SaaS, /api/v4/mcp returns 403 without 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
  3. Mint an OAuth access token with the mcp scope for that user, as an MCP client would.
  4. Turn on the Free observe flag and seed the _log counter 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) }
  5. Send tools/list to POST /api/v4/mcp with Authorization: Bearer <token>. Expect 200, and the key reads 3601.
  6. Turn on enforcement and seed the :limit rule'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) }
  7. Send the same request. Expect 429 with RateLimit-Name: sustained_mcp_traffic_per_user_free_plan.
  8. Run Feature.disable(:rate_limiter_mcp_sustained_limits_free_enforce) and send it again. Expect 200, 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.

Merge request reports

Loading
Loading