{
  "question": "How do I make MCP rate-limit and quota responses consistent across denial and empty-result paths?",
  "slug": "how-to-make-mcp-rate-limit-and-quota-responses-consistent-across-denial-and-empty-result-paths",
  "url": "https://www.rightshold.com/answers/how-to-make-mcp-rate-limit-and-quota-responses-consistent-across-denial-and-empty-result-paths",
  "short_answer": "Agents probe policy by comparing responses. If a denied tools/call returns HTTP 429 with RateLimit-Remaining: 0 while an empty-result success returns 200 with Remaining: 47, or if a policy deny returns isError: true with \"quota exceeded\" while a not-found returns a bare empty content array, the model can map which tools, tenants, or rows your gate treats specially. Pick one quota counter and one response shape for \"slow down / over budget\" and use it on every path that counts against the same limit — authz deny that still consumes a slot, empty business results, and hard throttle alike. Put the same Retry-After (or retryAfterSeconds) and remaining/limit fields in headers and in the JSON the model sees. Do not leak whether the reason was policy, emptiness, or throttle in fields the agent is allowed to read; keep that distinction in server-only logs.",
  "checklist": [
    "Inventory every path that can answer a tools/call: success with data, success with empty content, tool execution error (isError: true), JSON-RPC protocol error, and transport HTTP 429/401/403.",
    "Decide which of those paths consume quota. Charge the same bucket for \"ran and empty\" and \"denied by policy after auth\" if both reveal that the credential was accepted and the tool name was valid — otherwise an agent learns the difference by watching remaining drop on one path only.",
    "Define a single over-limit envelope: same HTTP status strategy per MCP method family, same RateLimit-* / Retry-After headers, and the same machine-readable retryAfter (seconds) in JSON-RPC error data or in the isError text the model reads.",
    "Follow MCP's two error classes deliberately: protocol/JSON-RPC errors for malformed or unknown tools; tool results with isError: true for execution and budget failures the model should see. Do not mix \"quota exceeded\" into an empty successful result.",
    "Normalize denial vs empty: when you must hide whether a row exists or a policy blocked it, return the same body, headers, and timing band for both (constant-shape empty or constant-shape error), and log the real reason only server-side.",
    "Never put different remaining, limit, or reset values on denial vs empty for the same credential and window. Agents compare those counters across probes.",
    "Test with a scripted client: call the same tool under (a) throttle, (b) policy deny, (c) empty dataset, and assert status, header names, remaining, and retryAfter match your matrix. Diff response bodies with a schema, not by eye.",
    "Record allow/deny and which envelope you emitted with record_ai_run (hashes of tool name, decision class, remaining, retryAfter) outside the agent session so you can audit side-channel fixes without giving the model the reason codes."
  ],
  "sections": [
    {
      "heading": "Why denial and empty must not disagree on quota",
      "points": [
        "MCP Security Considerations for tools tell servers to rate-limit invocations and sanitize outputs. Rate limits only help if the throttle signal is not itself a free oracle about policy or existence.",
        "MCP Tools define two error paths: JSON-RPC protocol errors (unknown tool, bad request) and tool execution errors returned as results with isError: true that clients SHOULD show the model for self-correction. Putting \"rate limit\" on one path and a silent empty content array on another teaches the model which probes are \"real\" denials.",
        "Production MCP gateways already document method-specific envelopes. Solo's agentgateway rate limiting returns isError: true for rate-limited tools/call, JSON-RPC -32003 with limit/remaining/retryAfterSeconds for other id'd methods, and HTTP 429 for notifications — and keeps rate-limit response headers on each. Copy that discipline: one matrix, applied every time, including when the business result would have been empty.",
        "Amazon Bedrock AgentCore's MCP throttle body puts retryAfter in the JSON-RPC error data. Whatever vendor you use, the important part for side-channels is sameness: the same credential hitting the same limit must see the same remaining and retry hints whether the underlying query would have returned rows or a policy deny."
      ]
    },
    {
      "heading": "A practical consistency matrix",
      "points": [
        "Transport throttle (burst / concurrency): prefer HTTP 429 with Retry-After plus RateLimit-Limit/Remaining/Reset (or your gateway's x-ratelimit-* equivalents) before tool code runs. Say the same retry seconds in any JSON body you also return.",
        "Per-tool or daily budget spent: return tools/call with isError: true and a short text that names the limit and when to retry — not a successful empty list that looks like \"no tickets found.\"",
        "Authz deny that should be indistinguishable from empty (existence or tenant isolation): return one fixed shape (either always empty success with no differentiating headers, or always the same isError budget/deny envelope) and do not decrement a visible remaining counter on only one of those paths.",
        "Unknown tool / malformed call: JSON-RPC protocol error. Do not burn per-tool budget in a way that moves RateLimit-Remaining differently than a well-formed call, or an agent can fingerprint valid tool names via the counter."
      ]
    },
    {
      "heading": "What can go wrong",
      "points": [
        "Policy deny returns 403 with Remaining: 0 while empty search returns 200 with Remaining: 12 — the agent maps which queries your ACL cares about.",
        "Throttle returns Retry-After: 20 but the isError text says \"try again in 60s,\" so the model retries on the wrong schedule and also learns you have two code paths.",
        "Empty results skip the rate-limit middleware entirely, so only denied calls move the counter — a classic side channel.",
        "Verbose error_description names the internal policy id or row id on deny but not on empty, leaking the distinction in the body even when headers match."
      ]
    }
  ],
  "checks": [],
  "ask_rightshold": {
    "start": "https://www.rightshold.com/start?ref=seo",
    "mcp_endpoint": "https://www.rightshold.com/mcp",
    "ask": "Record a signed receipt for this MCP rate-limit decision: hashes of the tool name, decision class (throttle|policy_deny|empty|ok), remaining, limit and retryAfter I returned — without putting the internal reason code in the agent session.",
    "mcp_call": {
      "tool": "record_ai_run",
      "arguments": {
        "model": "<mcp-server-or-gateway-id>",
        "provider": "mcp-quota",
        "prompt_sha256": "<sha256 of tool|credential_id|window>",
        "output_sha256": "<sha256 of decision_class|remaining|limit|retry_after_sec>",
        "params": {
          "tool": "search_tickets",
          "decision_class": "throttle",
          "remaining": 0,
          "limit": 60,
          "retry_after_sec": 20,
          "http_status": 200,
          "is_error": true
        },
        "ran_at": "2026-11-06T20:00:00Z"
      }
    },
    "also": [
      "Needs an API key. Send hashes in prompt_sha256/output_sha256; params may carry the non-secret counters you already returned to the client. verify_ai_run is free.",
      "precall_check (free) probes a server's live tools/list fingerprint before you trust it with quota-bearing credentials.",
      "set_spend_policy / authorize_spend add a money-side twin of the same idea: one policy, one allow/deny shape at spend time.",
      "How to rate limit an AI agent's API calls per key and How to test that MCP tool errors don't leak secrets cover per-key ceilings and secret-safe error bodies."
    ],
    "scope": "Rightshold records signed run receipts and spend decisions for agents that call it. It does not sit in your MCP gateway, emit RateLimit headers, or stop a server that returns different remaining values on deny vs empty. Enforce the consistency matrix in your gateway and tool handlers. Not legal advice."
  },
  "sources": [
    {
      "title": "Model Context Protocol specification: Tools (error handling; rate limit tool invocations)",
      "url": "https://modelcontextprotocol.io/specification/2025-11-25/server/tools"
    },
    {
      "title": "Solo agentgateway: Rate limiting for MCP (consistent envelopes per request type; limit/remaining/retryAfterSeconds)",
      "url": "https://docs.solo.io/agentgateway/kubernetes/latest/documentation/mcp/rate-limit/"
    },
    {
      "title": "Amazon Bedrock AgentCore: Rate limit enforcement (MCP JSON-RPC error with retryAfter)",
      "url": "https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway-rate-limits-enforcement.html"
    },
    {
      "title": "RFC 6585: Additional HTTP Status Codes (429 Too Many Requests)",
      "url": "https://www.rfc-editor.org/rfc/rfc6585"
    },
    {
      "title": "How to rate limit an AI agent's API calls per key (Rightshold)",
      "url": "https://www.rightshold.com/answers/how-to-rate-limit-an-ai-agents-api-calls-per-key"
    }
  ],
  "related": [
    {
      "title": "How to rate limit an AI agent's API calls per key",
      "url": "https://www.rightshold.com/answers/how-to-rate-limit-an-ai-agents-api-calls-per-key"
    },
    {
      "title": "How to test that MCP tool errors don't leak secrets",
      "url": "https://www.rightshold.com/answers/how-to-test-that-mcp-tool-errors-dont-leak-secrets"
    },
    {
      "title": "How to cap MCP tool output size to prevent context flooding",
      "url": "https://www.rightshold.com/answers/how-to-cap-mcp-tool-output-size-to-prevent-context-flooding"
    },
    {
      "title": "How to inventory MCP servers with scope, owner and revocation",
      "url": "https://www.rightshold.com/answers/how-to-inventory-mcp-servers-with-scope-owner-and-revocation"
    }
  ],
  "computed_from": "Curated guidance; license verdicts computed from Rightshold's license profiles at render time. Not legal advice.",
  "updated": "2026-11-06"
}