Report MCP tool rate limits with retry detail

What does this MR do and why?

The GitLab MCP server's tools call API endpoints that may be rate limited. When throttled, the endpoint sets a Retry-After header, but this was discarded before anything could read it. The tool result contained only "This endpoint has been requested too many times. Try again later." A client could not tell a throttle apart from a 404 or a 500, so it retried immediately and stayed throttled.

This exposes the retry detail in the tool result. Text content, which is what the model reads and self-corrects from:

Rate limited by search_rate_limit. Retry after 60 seconds.

structuredContent, keeping the existing error envelope so nothing else changes shape:

{
  "error": {
    "type": "rate_limited",
    "retry_after_seconds": 60,
    "limit": "search_rate_limit",
    "message": "This endpoint has been requested too many times. Try again later."
  }
}

Changes:

  1. app/services/mcp/tools/base/api_tool.rb re-dispatches into the Grape route in process. status, _, body = route.app.call(request.env) threw the headers away. It now captures them and passes them to process_response.
  2. app/services/mcp/tools/base/api_service.rb did the same for real HTTP responses in handle_response, reading response.code but ignoring response.headers.
  3. New shared module app/services/mcp/tools/base/rate_limited_response.rb, included by both, so every tool reports a throttle identically regardless of which path it took.
  4. Mcp::Tools::Base::Response.rate_limited_error builds the result.
  5. lib/api/helpers/rate_limiter.rb sets a RateLimit-Name header naming the throttle key that tripped. The key was already known at the point of failure but was never emitted. RateLimit-Name is an existing convention, already set by Rack::Attack in lib/gitlab/rack_attack/request_throttle_data.rb.
  6. A troubleshooting section was added to doc/user/model_context_protocol/mcp_server_troubleshooting.md.

Design points worth stating:

  • This stays a tool execution error with isError: true and HTTP 200, not a transport 429. The MCP specification lists API failures as tool execution errors and says clients SHOULD pass them to the model so it can self-correct. A transport 429 would move it into the category clients are less likely to surface usefully.
  • The response's RateLimit-* headers are deliberately not set from the inner endpoint. They stay reserved for the MCP Server's own limit, otherwise one response would carry two different limits under the same header names and a client could not tell which it was reading.
  • retry_after_seconds is the full rate limit period, not the time remaining in the window. Someone throttled five seconds into a sixty second window is told to wait sixty. It is a safe over-estimate, not a precise one.
  • No resets_at field, because ApplicationRateLimiter emits no reset time, so it would be now + interval presented as though it were authoritative.
  • Aggregated tools are covered without extra work. An aggregated tool routes to exactly one sub-tool and returns its response, and enhance_response_with_operation returns early on an error response, so a throttled result is never rewritten. There is a regression test pinning this.

References

Closes #629635 (closed)

Related to #629885 (closed)

Screenshots or screen recordings

There is no UI change. The client-visible change is the JSON result shape shown above, which now carries type, retry_after_seconds and limit under the existing error envelope.

How to set up and validate locally

  1. Lower a rate limit, such as the search rate limit, in the Admin settings.
  2. Call the MCP search tool past that limit.
  3. Confirm the result carries retry_after_seconds and limit in structuredContent rather than only prose.
  4. Repeat through the aggregated search tool to confirm it is not flattened.

Specs, all run locally and passing:

  • spec/services/mcp/tools/base/response_spec.rb, 37 examples
  • spec/services/mcp/tools/base/api_tool_spec.rb and api_service_spec.rb, 70 examples together
  • spec/services/mcp/tools/base/aggregated_service_spec.rb, 14 examples
  • spec/lib/api/helpers/rate_limiter_spec.rb, 22 examples
  • Full sweep of spec/services/mcp/ and spec/requests/api/mcp/, 2024 examples, 0 failures

Coverage includes the headers being absent, header casing variations, and a non JSON body.

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist.

Merge request reports

Loading
Loading