Skip to content

Rate limit tool calls on the HTTP transport - #521

Merged
alistair3149 merged 2 commits into
masterfrom
rate-limit-tool-calls
Jul 30, 2026
Merged

Rate limit tool calls on the HTTP transport#521
alistair3149 merged 2 commits into
masterfrom
rate-limit-tool-calls

Conversation

@alistair3149

@alistair3149 alistair3149 commented Jul 30, 2026

Copy link
Copy Markdown
Member

Closes the last confirmed gap from the 2026-07-28 spec-compliance audit. The spec has required servers to rate limit tool invocations since its first revision (2024-11-05); this server delegated it to the reverse proxy, which can only limit by IP. The division of labour this PR encodes: the proxy limits by address, the server limits by identity — neither can do the other's job.

What changes

tools/call on the HTTP transport is metered by a token bucket per authenticated caller, plus one shared bucket for all anonymous traffic. Over the limit: 429, Retry-After, and a JSON-RPC body on the new -31004 code echoing the request id — before anything reaches the wiki. Only tools/call is metered; discovery, initialize, cancellations and the held-open subscriptions/listen stream pass untouched, so a stream can never consume a token forever.

On by default: 30/s per caller (burst 60), 100/s shared anonymous (burst 200). MCP_RATE_LIMIT / MCP_RATE_LIMIT_BURST / MCP_RATE_LIMIT_ANONYMOUS tune it; MCP_RATE_LIMIT=0 disables. Stdio is untouched — one local user. mcp_rate_limited_total{caller} counts refusals on /metrics.

What a reviewer should weigh

The limiter runs after bearer resolution, not in front of the route. Keyed on the raw header, a caller rotating garbage bearers mints a fresh bucket per request — evading the limit and growing the map without bound. Placed after resolution, invalid bearers die at the existing 401 and never create state. The cost — JWT verification itself is unmetered — is an HMAC check, and the upstream refresh behind it is already coalesced.

Keys: proxy callers by the JWT's jti (the store lookup key, stable across both proxy-JWT and upstream-token refreshes; resolveUpstreamBearer now returns it alongside the access token — the one signature change). Forwarded bearers under the deprecated opt-in key on a token digest, so rotating tokens rotates buckets; that shape cannot be fairly limited, which is one more reason it is deprecated. Anonymous traffic shares one bucket by design: without a verified identity or a trusted X-Forwarded-For, a per-IP key behind the documented reverse proxy would collapse every caller into one bucket while looking per-caller. Per-IP anonymous fairness stays at the proxy; enabling Express trust proxy interacts with the existing resolveRequestProto header handling and is deliberately not bundled here.

Two request shapes had to be handled explicitly, because either one made the limiter decorative. A JSON-RPC batch is executed entry by entry on the legacy stateless leg, so it costs one token per tools/call entry and is refused whole when the cost does not fit. And express.json is typed on the SDK's own isJsonContentType: the SDK accepts a Content-Type that body-parser's default matcher rejects, which left req.body undefined while the handler read the raw stream — unmetered, and also skipping MCP_MAX_REQUEST_BODY. Both were found live by review, and both regression tests drive the real buildApp.

Forwarded bearers charge a shared allowance as well as their own. This server never verifies them, so a per-token bucket alone handed a fresh allowance to every random token; the shared bucket caps the whole deprecated path while distinct legitimate callers stay separated.

Eviction never frees a drained bucket. A full bucket is indistinguishable from no bucket, so under cap pressure (10k keys) only refilled-to-capacity buckets are swept — recreating a drained one would hand its caller the fresh burst the refusal exists to withhold. Overflow beyond a full cap is refused outright rather than delegated to the anonymous bucket, which passes everything when anonymous limiting is switched off.

Filed as Breaking because a working setup can hit it: bulk automation driving concurrent writes is a legitimate high-rate caller. Sequential automation at real wiki round-trip times sits well under the defaults.

Verification

Suite 1,732 green; typecheck, lint, format clean.

Every guard is mutation-bound — reverting each fix in turn fails tests: the route's limiter check (5), the bucket itself (15), batch cost accounting (2), the express.json type predicate (2), the shared passthrough allowance (1), and the overflow refusal (2).

Live against a real wiki with MCP_RATE_LIMIT_ANONYMOUS=2: burst passes, then 429 + Retry-After + -31004 with the id echoed, while tools/list passes the drained bucket. On the same drained bucket, a batch of 25 returns 429 with zero tool results, application/json; is metered, and a 3 MB body returns 413. This exercises the env → resolveHttpConfigcreateRateLimiterbuildApp wiring that route-level tests bypass.

Also in this PR

One stale paragraph pair in deployment.md's bearer section read as contradictory after #518 (list-wikis reports the wikis' authorization server vs. "a client cannot discover where to mint a token"); reconciled by scoping the first to the opt-in and the second to OAuth discovery.

🤖 Generated with Claude Code

alistair3149 and others added 2 commits July 30, 2026 13:13
The MCP specification has required servers to rate limit tool invocations since
its first revision; this server delegated that to the reverse proxy, which can
only limit by IP address. Only this server knows which signed-in user a request
acts as, so per-caller fairness has to live here: tools/call is now metered by a
token bucket per authenticated caller, with one shared bucket for anonymous
traffic — a flood backstop for the wiki, since without a verified identity or a
trusted X-Forwarded-For there is nothing honest to key on. A request over the
limit is refused 429 with Retry-After and a JSON-RPC body on the new -31004
code, before anything reaches the wiki.

The limiter runs after bearer resolution, which is load-bearing: keyed on the
raw header, a caller rotating garbage bearers would mint a fresh bucket per
request, evading the limit and growing the map without bound. Verified subjects
key on the proxy JWT's jti (stable across token refreshes; resolveUpstreamBearer
now returns it alongside the access token), forwarded bearers under the
deprecated opt-in on a digest of the token, and invalid bearers die at the
existing 401 without creating state. Only tools/call is metered — discovery,
initialize and the held-open subscriptions/listen stream pass untouched, so a
stream cannot consume a token forever.

Eviction exploits the bucket's one memoryless state: a full bucket is
indistinguishable from no bucket, so under cap pressure only refilled-to-capacity
buckets are dropped, never drained ones — recreating a drained bucket would hand
its caller the fresh burst the refusal exists to withhold. Overflow beyond the
cap shares the anonymous bucket rather than passing free.

On by default at 30/s per caller (burst 60) and 100/s shared anonymous;
MCP_RATE_LIMIT, MCP_RATE_LIMIT_BURST and MCP_RATE_LIMIT_ANONYMOUS tune it and
MCP_RATE_LIMIT=0 disables. mcp_rate_limited_total on /metrics counts refusals.
The limiter is per-process, matching the documented single-replica posture.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Review found both live: on a drained bucket, a JSON-RPC batch of 25 tool calls
returned 25 results, and a request sent as `application/json;` ran its tool every
time. Either one made the limiter decorative.

The legacy stateless leg executes a batch entry by entry, so a batch now costs one
token per tools/call entry and is refused whole when the cost does not fit —
wrapping calls in brackets buys nothing. The content-type hole was a mismatch
between two predicates: the SDK accepts a header body-parser's default matcher
rejects, so express left req.body undefined, the limiter saw no tool call, and the
handler read the raw stream anyway. express.json now types on the SDK's own
isJsonContentType, which cannot drift from what the handler accepts. That also
restores MCP_MAX_REQUEST_BODY, which the same gap had been bypassing.

Two further holes the review surfaced. A forwarded bearer is never verified by
this server, so a caller minting a random token per request minted a fresh
allowance per request; forwarded bearers now charge a shared allowance as well as
their own, capping the whole deprecated path. And an overflow key arriving when
every tracked bucket was mid-drain fell back to the anonymous bucket, which passes
everything when anonymous limiting is switched off; it is now refused outright.
The refusal also reports which allowance rejected it, so mcp_rate_limited_total
stops labelling a shared-bucket refusal as the caller's own.

The regression tests drive the real buildApp: both bypasses were invisible to
tests that stubbed the handler or built their own express app, and a first attempt
at a content-type test reproduced that mistake — it inlined the fix in its own
harness and passed against the unfixed server.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@alistair3149
alistair3149 marked this pull request as ready for review July 30, 2026 17:59
@alistair3149
alistair3149 merged commit 2e42985 into master Jul 30, 2026
1 check passed
@alistair3149
alistair3149 deleted the rate-limit-tool-calls branch July 30, 2026 18:00
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