Skip to content

feat(mcp): apply rate-limit + budget governance to MCP tool calls - #669

Merged
moonming merged 2 commits into
mainfrom
feat/mcp-rate-limit
Jun 30, 2026
Merged

feat(mcp): apply rate-limit + budget governance to MCP tool calls#669
moonming merged 2 commits into
mainfrom
feat/mcp-rate-limit

Conversation

@moonming

@moonming moonming commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

What

DP-4 slice 4 (governance reuse). An MCP tools/call now passes through the same rate-limit and budget gate as an LLM request — quota::enforce(&state, &auth, None), keyed on the caller's API key. This is the core of the "single-pipeline same-governance" story: the same key, the same limiter, the same budget govern LLM and MCP work.

How

  • Gate placement — in the /mcp handler, after auth, before the gateway. quota::enforce applies the key's inline rate_limit (RPM/TPM/concurrency) and the budget pre-check (budgets.check). On 429 / budget-exceeded it returns before any upstream is contacted.
  • Scoped to the unit of work — the handler peeks at the JSON-RPC method and only meters tools/call. initialize / tools/list (handshake + discovery) pass through ungated, so a client can always connect and enumerate even when the key is at its tool-call limit. (Reading the method means buffering the body — already size-capped by the global body-limit layer — and rebuilding the request for the gateway.)
  • No token cost — a tool call carries no token figure (per-tool cost is a separate future concern), so the reservation is dropped after the call without committing tokens, which releases the concurrency slot. Budget is a gating check here, not a charge.

Test plan

  • With an rpm=1 key: first tools/call passes the gate (non-429), second tools/call within the minute → 429, and initialize is never rate-limited (proves handshake/discovery bypass).
  • cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings, cargo test -p aisix-proxy (508 pass), green.

Scope / remaining

  • This delivers rate-limit + budget reuse over MCP. The last slice — guardrails over MCP — is separate and meatier (guardrails are built around chat text, so MCP tool-call arguments and results need a JSON↔text adapter to flow through guardrail_index.resolve + check_input/check_output). And UsageEvent emission for MCP calls.

Refs AISIX-Cloud#894

Summary by CodeRabbit

  • Bug Fixes
    • Added rate-limit enforcement to MCP tool calls, helping prevent excessive usage before requests reach the backend.
    • MCP connection setup now respects existing request limits while leaving initial handshake and discovery steps unaffected.
    • Improved handling of incoming MCP requests so they continue to work reliably after being inspected for routing and limits.

An MCP `tools/call` now goes through the same rate-limit and budget gate
as an LLM request (`quota::enforce`, keyed on the caller's API key) — so
MCP work is governed by the same key object and the same limiter/budget as
LLM work. On 429 / budget-exceeded the call is rejected before any upstream
is contacted.

The gate is scoped to the unit of work: the `/mcp` handler peeks at the
JSON-RPC `method` and only meters `tools/call`. `initialize` / `tools/list`
(handshake + discovery) pass through ungated, so a client can always
connect and enumerate even when the key is at its tool-call limit. The
reservation carries no token cost (a tool call has none yet) and is dropped
after the call, releasing the concurrency slot.

To read the method the handler buffers the body (already size-capped by the
global body-limit layer) and rebuilds the request for the gateway.

Tested: with an rpm=1 key, the first tools/call passes and the second is
429, while initialize is never rate-limited.

Refs AISIX-Cloud#894
@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@moonming, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 50 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f9de1892-1431-4764-b8e7-932a63996091

📥 Commits

Reviewing files that changed from the base of the PR and between c797dcd and 6ab4cce.

📒 Files selected for processing (1)
  • crates/aisix-proxy/src/mcp.rs
📝 Walkthrough

Walkthrough

mcp_endpoint is updated to buffer the request body, parse the JSON-RPC method field, and apply crate::quota::enforce gating exclusively for tools/call requests. Non-tools/call methods bypass quota checks. The MCP gateway is now constructed after the gating decision. Tests add a rate-limited snapshot and verify that repeated tools/call requests are rate-limited while initialize is not.

Changes

MCP tools/call rate limiting

Layer / File(s) Summary
Rate-limit gating in mcp_endpoint and tests
crates/aisix-proxy/src/mcp.rs
mcp_endpoint buffers the request body, peeks the JSON-RPC method, calls quota::enforce for tools/call (returning error immediately on failure), then rebuilds the request and dispatches to StreamableHttpService. Tests verify the second tools/call is rate-limited and initialize bypasses gating.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 5 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
E2e Test Quality Review ⚠️ Warning Coverage is only in-process router integration; it misses a true E2E tool-call path and the authenticated body-limit boundary still returns 400 instead of 413. Add an authenticated oversized-body test and make to_bytes limit hits return 413/RequestTooLarge; if you want E2E, exercise a real upstream MCP server path.
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding rate-limit and budget governance to MCP tool calls.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Security Check ✅ Passed No security issues found; /mcp auth precedes buffering, secrets aren’t logged/serialized, and API keys are hashed in storage.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/mcp-rate-limit

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/aisix-proxy/src/mcp.rs`:
- Around line 43-49: The buffering path in mcp.rs currently treats every
to_bytes failure the same, but request-size limit hits should be reported as 413
instead of 400. Update the request body handling around to_bytes in the MCP
request flow to distinguish body-cap/request-too-large errors from malformed
body errors, and map the limit case to StatusCode::PAYLOAD_TOO_LARGE (or
RequestTooLarge) while keeping invalid bodies as BAD_REQUEST.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1c1b061b-85fe-4259-9c13-338182adc8e5

📥 Commits

Reviewing files that changed from the base of the PR and between d6adc8a and c797dcd.

📒 Files selected for processing (1)
  • crates/aisix-proxy/src/mcp.rs

Comment on lines +43 to +49
// Buffer the body so the JSON-RPC method can be inspected, then rebuilt for
// the gateway. The global body-limit layer has already capped the size.
let (parts, body) = request.into_parts();
let bytes = match to_bytes(body, state.request_body_limit_bytes).await {
Ok(bytes) => bytes,
Err(_) => return (StatusCode::BAD_REQUEST, "invalid request body").into_response(),
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect body-limit/error handling paths before choosing the exact local mapping.
rg -n -C3 'RequestBodyLimit|DefaultBodyLimit|RequestTooLarge|to_bytes\(body' crates/aisix-proxy

Repository: api7/aisix

Length of output: 10687


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,180p' crates/aisix-proxy/src/mcp.rs

Repository: api7/aisix

Length of output: 7493


Map body-cap failures to 413
to_bytes(...) can hit the request-size limit during buffering. The blanket 400 invalid request body path masks that case; return 413 Payload Too Large/RequestTooLarge for limit hits instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/aisix-proxy/src/mcp.rs` around lines 43 - 49, The buffering path in
mcp.rs currently treats every to_bytes failure the same, but request-size limit
hits should be reported as 413 instead of 400. Update the request body handling
around to_bytes in the MCP request flow to distinguish
body-cap/request-too-large errors from malformed body errors, and map the limit
case to StatusCode::PAYLOAD_TOO_LARGE (or RequestTooLarge) while keeping invalid
bodies as BAD_REQUEST.

Audit (CLAUDE.md §8) on #669 returned APPROVE with a LOW coverage note:
only `initialize` was tested as bypassing the rate gate. Added a
`tools/list`-at-the-limit assertion (refactored the request builder into a
shared `mcp_request(method, params)` helper) so both non-tool-call methods
are pinned as ungated.

Refs AISIX-Cloud#894
@moonming

Copy link
Copy Markdown
Collaborator Author

Independent audit (CLAUDE.md §8): APPROVE

Auditor verified cold against source (incl. rmcp 1.8.0) + ran build/test/clippy in an isolated worktree — all green. Key confirmations:

  • No rate-limit bypass. rmcp dispatches to call_tool only on an exact-byte method == "tools/call" (untagged enum), and JsonRpcMessage has no batch/array variant — so a malformed/non-JSON/batch/tools/list/initialize body is is_tool_call=false and can never execute a tool. "Ungate everything but tools/call" is safe by construction; the handler's peek uses the same discriminator as rmcp's dispatch.
  • Reservation lifetime correct. Drop releases only the concurrency slot (RPM increment persists → 2nd call 429), charges zero tokens, runs even on unwind. No leak.
  • enforce(None) enforces the key's own rate_limit + budget (not a no-op); test budget is disabled → allow_all so no false-positive 429; 429 envelope leaks no key/internal.
  • DoS-safe: body already capped by the global limit layer before to_bytes.

Two LOW findings; one folded in (commit pushed):

  • Coverage: tools/list-at-the-limit not tested → added (now both initialize and tools/list are pinned as ungated).
  • Latency note (every /mcp request now buffered): negligible for small JSON-RPC, LLM endpoints buffer identically — no change.

Merging on CI green.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant