MCP tool results give no actionable information when a rate limit is hit

Problem

When an MCP tool hits a rate limit on the endpoint it calls, the client is told it failed but not that it was throttled, or for how long.

A tool that trips search_rate_limit comes back as a tool error whose entire content is the sentence "This endpoint has been requested too many times. Try again later." The endpoint set a Retry-After, but it is thrown away before anything can read it, so nothing in the result says how long to wait or that this was a throttle at all. An agent cannot tell it apart from any other failure, so it retries immediately and stays throttled.

Proposal

Keep reporting it as a tool execution error, and give the error something actionable in it: how long to wait, which limit tripped, when it resets.

The MCP specification is explicit that this stays a tool error rather than becoming a transport 429. API failures are listed as tool execution errors, reported in the result with isError: true, and clients are told they SHOULD pass those to the model so it can self-correct. Protocol errors are only unknown tools, malformed requests and server errors. So the current status code is right; the payload is not.

This is separate from the MCP Server's own rate limit, which refuses the request before any tool runs and correctly returns a 429.

Works for plain and aggregated API tools alike.

Implementation plan

  1. Read the inner response's headers in ApiTool#execute instead of discarding them unread. They are not forwarded to the client; the values are lifted out and put in the result.
  2. On a 429, build the tool error with structured retry data in structuredContent, where error details already go, rather than flattening to text.
  3. Same treatment in ApiService#handle_response, which drops headers from loopback responses.
  4. Decide the shape of that structured payload once, so every tool reports throttling identically.
Detail

What a client sees today

{ "content": [{ "type": "text",
    "text": "This endpoint has been requested too many times. Try again later." }],
  "structuredContent": { "error": { "message": "..." } },
  "isError": true }

No retry delay, no limit name, nothing a client can branch on.

What the specification says

From Tools, Error Handling (revision 2026-07-28):

Tool Execution Errors contain actionable feedback that language models can use to self-correct and retry with adjusted parameters:

  • API failures
  • Input validation errors
  • Business logic errors

They are reported in tool results with isError: true

and

Clients SHOULD provide tool execution errors to language models to enable self-correction.

A rate limit from the endpoint a tool called is an API failure. Turning it into a protocol-level error or a transport 429 would move it into the category the spec says clients are less likely to surface usefully. The bar it currently fails is "actionable feedback" — a sentence with no delay in it is not actionable.

The spec defines no JSON-RPC error code for exhaustion, and its reserved -32020–-32099 range holds only three codes, none applicable.

Why the headers are not forwarded

The response already carries RateLimit-* headers describing the MCP Server's own limit, added by the Rack middleware. If a tool also set them from an inner endpoint's throttle, one response would carry two limits under the same header names and a client could not tell which it was reading. The headers stay reserved for the MCP limit; the inner limit's detail goes in the result.

Suggested shape

Text, 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."
  }
}

type is the field that earns its place: it lets a client branch without matching English, and makes a throttle distinguishable from a 404. limit says which of the stacked limits tripped, which matters because one tool call can consume several. message preserves today's text so nothing regresses. Snake case matches the parsed REST bodies these tools already return.

Worth documenting rather than glossing: Retry-After from too_many_requests! is the full period, not the time left in the window, so a user 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: ApplicationRateLimiter emits no RateLimit-ResetTime, so it would be now + interval dressed up as authoritative.

Where it breaks

All references at commit f5365395b.

ApiTool#execute re-dispatches into the Grape route in-process and throws the headers away:

status, _, body = route.app.call(request.env)

Retry-After is in that middle element, set by too_many_requests!. process_response then folds every status >= 400 into the same shape, so a throttle is indistinguishable from a 404 or a 500.

ApiService#handle_response does the same for real HTTP responses: reads response.code, ignores response.headers.

Why fixing ApiTool covers aggregated tools too

An aggregated tool routes to exactly one sub-tool and returns its response. AggregatedService#perform_default selects and executes it directly.

search_service and semantic_search_service use that base method unchanged, so the sub-tool's response passes straight through. pipeline_service and save_merge_request_service override it to attach operation metadata, but that helper returns early on an error response, so a throttled result is never rewritten.

Tests should cover both shapes so it stays that way.

Verification

Set search_rate_limit low, call the MCP search tool past it, and confirm the result carries the retry delay in structuredContent rather than only prose. Repeat through search_service to confirm an aggregated tool does not flatten it.