Skip to content

feat(server): add WithRateLimit per-session rate-limit middleware - #910

Open
ultramcu wants to merge 3 commits into
mark3labs:mainfrom
ultramcu:feat/ratelimit-middleware
Open

feat(server): add WithRateLimit per-session rate-limit middleware#910
ultramcu wants to merge 3 commits into
mark3labs:mainfrom
ultramcu:feat/ratelimit-middleware

Conversation

@ultramcu

@ultramcu ultramcu commented Jun 28, 2026

Copy link
Copy Markdown

Description

Adds a built-in per-session rate-limit tool-handler middleware, as proposed and discussed in #896.

WithRateLimit(opts) installs a token-bucket limiter (via golang.org/x/time/rate) in front of the tool-handler chain, mirroring the existing WithLogger / WithRecovery option pattern:

srv := server.NewMCPServer("svc", "1.0.0",
    server.WithRateLimit(server.RateLimitOpts{
        RPS:   5,            // per-session sustained rate
        Burst: 10,           // per-session burst
        // optional global ceiling, KeyFunc, OnDeny, reaper tuning, Logger
    }),
)
  • Per-key buckets keyed by ClientSessionFromContext (a no-session caller falls back to a single shared bucket, so it can't bypass the limit). Override with KeyFunc for tenant/claim-based keying.
  • Optional global ceiling (GlobalRPS/GlobalBurst) checked before the per-key bucket.
  • Pluggable OnDeny for the denied-request response.
  • Lazy, self-terminating reaper evicts idle limiters — it starts on first use and exits when the map drains, so an idle server holds zero extra goroutines. ReapInterval < 0 disables it.

Fixes #896.

Type of Change

  • New feature (non-breaking change that adds functionality)

Checklist

  • My code follows the code style of this project
  • I have performed a self-review of my own code
  • I have added tests that prove my fix is effective or that my feature works
  • I have updated the documentation accordingly

Additional Information

On the denial response / JSON-RPC code. The issue sketched a JSON-RPC -32029 error for denials, but a ToolHandlerFunc can't surface a custom JSON-RPC code today — the dispatch path maps any handler/middleware error to INTERNAL_ERROR (-32603), and the requestError type that carries a code is unexported. So the default OnDeny returns mcp.NewToolResultError("rate limit exceeded …") (an IsError CallToolResult), consistent with how this package surfaces validation failures. Callers who want different semantics can supply their own OnDeny. Happy to revisit if a typed protocol-level error becomes returnable from handlers.

Tests. server/ratelimit_test.go covers deny-after-burst, per-session isolation, the global ceiling, custom KeyFunc/OnDeny, concurrency (under -race), and reaper eviction; plus construction-validation and the no-session fallback. The full suite (incl. the otel/ submodule, whose go.sum is updated for the new dep) and go test -race pass.

Follow-ups. Logging/metrics hooks can build on the shipped WithLogger (#892) and the pending WithMeter (#893) — @tarunbtw kindly offered to pair on that integration.

Summary by CodeRabbit

  • New Features
    • Added configurable per-key rate limiting for tool calls, including per-session/custom key throttling and optional global ceilings.
    • When limits are exceeded, tool execution is blocked and a configurable denial response is returned.
    • Automatic cleanup of idle rate-limit entries; reaper can be disabled and unauthenticated calls share a fallback bucket.
  • Tests
    • Added unit and behavioral tests covering denial after burst, per-key isolation, global ceilings, custom keying, custom/default denials, concurrency, and eviction/reaper behavior.

@mark-iii-labs-huly

Copy link
Copy Markdown

Connected to Huly®: MCP_G-474

@coderabbitai

coderabbitai Bot commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 05a3c8b4-0d5e-4fe1-9f5b-a3674cf1ea71

📥 Commits

Reviewing files that changed from the base of the PR and between 8463b74 and 2257324.

⛔ Files ignored due to path filters (2)
  • go.sum is excluded by !**/*.sum
  • otel/go.sum is excluded by !**/*.sum
📒 Files selected for processing (5)
  • go.mod
  • otel/go.mod
  • server/ratelimit.go
  • server/ratelimit_extra_test.go
  • server/ratelimit_test.go
✅ Files skipped from review due to trivial changes (1)
  • otel/go.mod
🚧 Files skipped from review as they are similar to previous changes (4)
  • go.mod
  • server/ratelimit_extra_test.go
  • server/ratelimit_test.go
  • server/ratelimit.go

Walkthrough

Adds WithRateLimit(RateLimitOpts) for tool calls, with per-key token buckets, an optional global limiter, lazy idle-key eviction, configurable denial handling, and tests covering admission, concurrency, and reaper behavior.

Changes

Rate Limit Middleware

Layer / File(s) Summary
RateLimitOpts config, store internals, and defaults
go.mod, otel/go.mod, server/ratelimit.go
Adds golang.org/x/time v0.15.0, defines package constants, RateLimitOpts, internal store and entry types, store initialization, and default key/deny behavior.
Middleware, allow, touch, and reaper lifecycle
server/ratelimit.go
Implements key derivation, admission checks, denial logging and dispatch, lazy limiter creation, and the reaper sweep/eviction loop.
Behavioral and edge-case tests
server/ratelimit_test.go, server/ratelimit_extra_test.go
Covers burst denial, per-key isolation, global ceiling, custom keying/denial handling, default deny behavior, concurrency, reaper eviction, panic validation, fallback bucket, and disabled reaper behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main change: adding WithRateLimit middleware for per-session tool rate limiting.
Description check ✅ Passed The description covers the change, type, checklist, and implementation notes, and includes a Fixes #896`` reference.
Linked Issues check ✅ Passed The PR implements the requested per-session limiter, optional global cap, eviction, configurable deny handling, and tests, matching #896.
Out of Scope Changes check ✅ Passed The dependency additions and tests support the rate-limit feature and do not appear unrelated to #896.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (1)
server/ratelimit_extra_test.go (1)

13-22: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Make the invalid-options coverage a named table test.

This already exercises multiple bad configurations, so a tests := []struct{ name string; opts RateLimitOpts } + t.Run shape would align better with the repo’s test conventions and make it easier to add the missing negative-StaleTTL case once validation is tightened. As per coding guidelines, **/*_test.go: "implement table-driven tests with tests := []struct{ name, ... }; test files must end in _test.go".

🤖 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 `@server/ratelimit_extra_test.go` around lines 13 - 22, Refactor
TestRateLimit_PanicsOnBadOpts into a named table-driven test using a tests slice
with name and RateLimitOpts fields, and wrap each case in t.Run so the
invalid-option coverage matches repo test conventions. Keep the existing panic
assertion around WithRateLimit, and include the current bad
RPS/Burst/GlobalBurst cases as named entries so it is easy to add the missing
negative StaleTTL case later.

Source: Coding guidelines

🤖 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 `@server/ratelimit.go`:
- Around line 76-79: Reject negative StaleTTL values in the rate limit config:
when reaping is enabled, a negative ttl makes the sweep cutoff land in the
future and clears all limiters every tick. Add validation in the option setup
path for WithRateLimit and the reaper setup around ReapInterval/StaleTTL so that
StaleTTL < 0 panics or fails fast, matching the existing bad-options behavior.
Use the StaleTTL field and the reaper/cutoff logic in the rate limiter
implementation to locate the checks.
- Around line 227-230: The global-deny path in the rate limiter is creating
per-key state via st.touch(key), which can populate entries[key] for requests
that never reach the per-key allow path. Update the logic in the rate-limiting
flow around st.touch, Allow, and the per-key limiter creation so global denials
only refresh lastSeen for an already-existing key and do not allocate a new
entry. Keep limiter creation confined to the per-key allow path, and apply the
same adjustment wherever this global-deny handling is duplicated.
- Around line 81-82: The denied-call warning in the rate limiter is missing the
documented session ID, so the `Logger` behavior in `server/ratelimit.go` no
longer matches the comment. Update the logging path in the denied-call handling
used by `RateLimit`/its helper so the Warn entry includes the session ID
alongside the derived key and tool name, or adjust the `Logger` field docs if
that identity is intentionally unavailable after `KeyFunc` transforms it.

---

Nitpick comments:
In `@server/ratelimit_extra_test.go`:
- Around line 13-22: Refactor TestRateLimit_PanicsOnBadOpts into a named
table-driven test using a tests slice with name and RateLimitOpts fields, and
wrap each case in t.Run so the invalid-option coverage matches repo test
conventions. Keep the existing panic assertion around WithRateLimit, and include
the current bad RPS/Burst/GlobalBurst cases as named entries so it is easy to
add the missing negative StaleTTL case later.
🪄 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: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 8ea0b267-0324-4f67-bda8-3a2174ac3b6e

📥 Commits

Reviewing files that changed from the base of the PR and between b6e6224 and 050138e.

⛔ Files ignored due to path filters (2)
  • go.sum is excluded by !**/*.sum
  • otel/go.sum is excluded by !**/*.sum
📒 Files selected for processing (4)
  • go.mod
  • server/ratelimit.go
  • server/ratelimit_extra_test.go
  • server/ratelimit_test.go

Comment thread server/ratelimit.go
Comment on lines +76 to +79
// StaleTTL is how long a key may be idle before its limiter is evicted by
// the reaper. 0 selects the default of 10m. Ignored when the reaper is
// disabled (ReapInterval < 0).
StaleTTL time.Duration

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Reject negative StaleTTL values.

With ttl < 0, cutoff := now.Add(-ttl) lands in the future, so every sweep deletes every key. That effectively resets exhausted buckets on each reap tick instead of only evicting idle limiters. Since WithRateLimit already fails fast on bad opts, this should also panic when reaping is enabled and StaleTTL < 0.

Suggested fix
 func WithRateLimit(opts RateLimitOpts) ServerOption {
 	if opts.RPS <= 0 {
 		panic(fmt.Sprintf("server.WithRateLimit: RPS must be > 0, got %v", opts.RPS))
 	}
 	if opts.Burst < 1 {
 		panic(fmt.Sprintf("server.WithRateLimit: Burst must be >= 1, got %d", opts.Burst))
 	}
 	if opts.GlobalRPS > 0 && opts.GlobalBurst < 1 {
 		panic(fmt.Sprintf("server.WithRateLimit: GlobalBurst must be >= 1 when GlobalRPS > 0, got %d", opts.GlobalBurst))
 	}
+	if opts.ReapInterval >= 0 && opts.StaleTTL < 0 {
+		panic(fmt.Sprintf("server.WithRateLimit: StaleTTL must be >= 0 when reaping is enabled, got %s", opts.StaleTTL))
+	}
 
 	store := newRateLimitStore(opts)

Also applies to: 99-111, 288-300

🤖 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 `@server/ratelimit.go` around lines 76 - 79, Reject negative StaleTTL values in
the rate limit config: when reaping is enabled, a negative ttl makes the sweep
cutoff land in the future and clears all limiters every tick. Add validation in
the option setup path for WithRateLimit and the reaper setup around
ReapInterval/StaleTTL so that StaleTTL < 0 panics or fails fast, matching the
existing bad-options behavior. Use the StaleTTL field and the reaper/cutoff
logic in the rate limiter implementation to locate the checks.

Comment thread server/ratelimit.go
Comment on lines +81 to +82
// Logger, when non-nil, receives a Warn line per denied call with the key,
// tool name, and session ID. When nil, denials are not logged.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Denied-call logging is missing the documented session ID.

Right now the warning line only includes the derived key and tool name. When KeyFunc collapses or rewrites session identities, that loses the caller identity the Logger field promises. Either add the session ID attribute here or relax the field docs.

Also applies to: 209-213

🤖 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 `@server/ratelimit.go` around lines 81 - 82, The denied-call warning in the
rate limiter is missing the documented session ID, so the `Logger` behavior in
`server/ratelimit.go` no longer matches the comment. Update the logging path in
the denied-call handling used by `RateLimit`/its helper so the Warn entry
includes the session ID alongside the derived key and tool name, or adjust the
`Logger` field docs if that identity is intentionally unavailable after
`KeyFunc` transforms it.

Comment thread server/ratelimit.go

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (3)
server/ratelimit.go (2)

263-269: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Pass context.Context into the long-running reaper.

reap is a long-running goroutine but does not accept context.Context as its first parameter.

Proposed fix
-	go st.reap(st.reapInterval, st.staleTTL, st.reaperStop)
+	go st.reap(context.Background(), st.reapInterval, st.staleTTL, st.reaperStop)
 }
@@
-func (st *rateLimitStore) reap(interval, ttl time.Duration, stop <-chan struct{}) {
+func (st *rateLimitStore) reap(ctx context.Context, interval, ttl time.Duration, stop <-chan struct{}) {
 	ticker := time.NewTicker(interval)
 	defer ticker.Stop()
 
 	for {
 		select {
+		case <-ctx.Done():
+			return
 		case <-stop:
 			return

As per coding guidelines, “Always accept context.Context as the first parameter in handlers and long-running functions.”

🤖 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 `@server/ratelimit.go` around lines 263 - 269, The long-running reaper should
follow the context-first guideline: update rateLimitStore.reap to accept
context.Context as its first parameter, and thread that context through the
goroutine startup and any blocking loop/stop checks so the reaper can be
canceled cleanly. Locate the call site that starts the reaper and the reap
method itself, and replace the custom stop-channel-only flow with context-based
cancellation while preserving the existing idle-eviction behavior.

Source: Coding guidelines


144-148: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Document what the mutex protects.

rateLimitStore has shared mutable state guarded by mu; add a short comment documenting the protected fields and thread-safety boundary.

Proposed fix
-	mu         sync.Mutex
+	mu         sync.Mutex // guards entries, reaperOn, and reaperStop
 	entries    map[string]*rateLimitEntry

As per coding guidelines, “Use sync.Mutex for shared state and document thread-safety requirements in comments.”

🤖 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 `@server/ratelimit.go` around lines 144 - 148, The rateLimitStore mutex needs a
thread-safety comment documenting what shared state it guards. Add a short
comment on the mu field in rateLimitStore explaining that it protects entries,
reaperOn, and reaperStop, and that now is the injectable clock rather than
protected mutable state. Keep the wording aligned with the existing struct
fields so the boundary is clear for future changes.

Source: Coding guidelines

server/ratelimit_test.go (1)

365-378: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert that reaping restores the full burst, not just one token.

This loop stops after the first allowed call, so an implementation that recreates the limiter with Burst: 1 would still pass. After regained, spend the remaining burst-1 calls and assert the next call is denied again to lock down the “fresh bucket” contract from the PR objective.

🤖 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 `@server/ratelimit_test.go` around lines 365 - 378, The idle-key reaper test
currently only verifies that a request becomes allowed again, which can miss a
limiter recreated with only one token. Update the test around the regained check
in ratelimit_test.go to use the existing rlCall and denied helpers to consume
the remaining burst after the first allowed request, then assert the next call
is denied so the fresh bucket behavior is enforced.
🤖 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 `@server/ratelimit_test.go`:
- Around line 47-71: The helper rlCall in server/ratelimit_test.go should not
use require.Truef for the type assertion because it can stop only the worker
goroutine and leave TestRateLimit_Concurrency hanging. Update rlCall to return
an error when the result is not a *mcp.CallToolResult, and move the assertion to
the caller after wg.Wait() so failures are handled in the main test flow.

In `@server/ratelimit.go`:
- Around line 81-83: Update the Logger field contract in the rate limit
middleware so the godoc matches actual behavior: the Logger comment currently
promises key, tool name, and session ID, but the logging path only includes the
key and tool name. Either revise the comment attached to Logger in ratelimit.go
to remove the session ID mention, or, if intended, update the denied-call
logging code in the rate-limit middleware to actually emit the session ID as
well; keep the contract and implementation consistent in both referenced
locations.
- Around line 55-58: The default deny message documentation in the OnDeny godoc
is out of sync with the actual behavior in the rate limiter. Update the comment
near OnDeny to match the exact string returned by the default deny path in the
rate limit handler, or change the default deny branch in the rate-limit logic to
return the documented message. Use the OnDeny documentation and the default deny
implementation in server/ratelimit.go as the source of truth.
- Around line 102-111: In WithRateLimit, add validation for
RateLimitOpts.StaleTTL so negative values are rejected up front; this
misconfiguration causes sweep to calculate a future cutoff and can evict active
entries repeatedly. Mirror the existing RPS/Burst checks by panicking with a
clear message when opts.StaleTTL < 0, and keep the validation alongside the
other RateLimitOpts guardrails in WithRateLimit.

---

Nitpick comments:
In `@server/ratelimit_test.go`:
- Around line 365-378: The idle-key reaper test currently only verifies that a
request becomes allowed again, which can miss a limiter recreated with only one
token. Update the test around the regained check in ratelimit_test.go to use the
existing rlCall and denied helpers to consume the remaining burst after the
first allowed request, then assert the next call is denied so the fresh bucket
behavior is enforced.

In `@server/ratelimit.go`:
- Around line 263-269: The long-running reaper should follow the context-first
guideline: update rateLimitStore.reap to accept context.Context as its first
parameter, and thread that context through the goroutine startup and any
blocking loop/stop checks so the reaper can be canceled cleanly. Locate the call
site that starts the reaper and the reap method itself, and replace the custom
stop-channel-only flow with context-based cancellation while preserving the
existing idle-eviction behavior.
- Around line 144-148: The rateLimitStore mutex needs a thread-safety comment
documenting what shared state it guards. Add a short comment on the mu field in
rateLimitStore explaining that it protects entries, reaperOn, and reaperStop,
and that now is the injectable clock rather than protected mutable state. Keep
the wording aligned with the existing struct fields so the boundary is clear for
future changes.
🪄 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: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 8ea0b267-0324-4f67-bda8-3a2174ac3b6e

📥 Commits

Reviewing files that changed from the base of the PR and between b6e6224 and 050138e.

⛔ Files ignored due to path filters (2)
  • go.sum is excluded by !**/*.sum
  • otel/go.sum is excluded by !**/*.sum
📒 Files selected for processing (4)
  • go.mod
  • server/ratelimit.go
  • server/ratelimit_extra_test.go
  • server/ratelimit_test.go

Comment thread server/ratelimit_test.go
Comment on lines +47 to +71
func rlCall(t *testing.T, s *MCPServer, sessionID, toolName string) (*mcp.CallToolResult, error) {
t.Helper()
ctx := s.WithContext(context.Background(), fakeSession{
sessionID: sessionID,
notificationChannel: make(chan mcp.JSONRPCNotification, 1),
initialized: true,
})
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: toolName,
Arguments: map[string]any{},
},
}
res, reqErr := s.handleToolCall(ctx, 1, req)
if reqErr != nil {
return nil, reqErr.err
}
// handleToolCall returns `any` on success; for tools/call it is a
// *mcp.CallToolResult.
if res == nil {
return nil, nil
}
ctr, ok := res.(*mcp.CallToolResult)
require.Truef(t, ok, "expected *mcp.CallToolResult, got %T", res)
return ctr, nil

Copy link
Copy Markdown
Contributor

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
set -euo pipefail

sed -n '1,180p' server/ratelimit_test.go
printf '\n---\n'
sed -n '180,420p' server/ratelimit_test.go

Repository: mark3labs/mcp-go

Length of output: 1920


🏁 Script executed:

#!/bin/bash
set -euo pipefail

git ls-files server/ratelimit_test.go
printf '\n---\n'
sed -n '1,120p' server/ratelimit_test.go
printf '\n---\n'
sed -n '320,420p' server/ratelimit_test.go

Repository: mark3labs/mcp-go

Length of output: 1920


Avoid require.Truef in rlCall
rlCall is used from worker goroutines in TestRateLimit_Concurrency; require calls FailNow, which can stop only that goroutine and leave the test hanging if the type assertion ever fails. Return an error here and assert after wg.Wait() 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 `@server/ratelimit_test.go` around lines 47 - 71, The helper rlCall in
server/ratelimit_test.go should not use require.Truef for the type assertion
because it can stop only the worker goroutine and leave
TestRateLimit_Concurrency hanging. Update rlCall to return an error when the
result is not a *mcp.CallToolResult, and move the assertion to the caller after
wg.Wait() so failures are handled in the main test flow.

Comment thread server/ratelimit.go
Comment on lines +55 to +58
// OnDeny produces the response returned to the caller when a call is denied
// (the wrapped handler is not invoked). When nil, the default returns a
// tool execution error result (mcp.NewToolResultError) carrying the message
// "rate limit exceeded".

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Keep the default deny message documentation in sync.

Line 57 says the default message is "rate limit exceeded", but Line 196 returns "rate limit exceeded: too many tool calls, please retry later". Please update the godoc to the exact message or make the implementation match the documented contract.

Also applies to: 195-196

🤖 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 `@server/ratelimit.go` around lines 55 - 58, The default deny message
documentation in the OnDeny godoc is out of sync with the actual behavior in the
rate limiter. Update the comment near OnDeny to match the exact string returned
by the default deny path in the rate limit handler, or change the default deny
branch in the rate-limit logic to return the documented message. Use the OnDeny
documentation and the default deny implementation in server/ratelimit.go as the
source of truth.

Comment thread server/ratelimit.go
Comment on lines +81 to +83
// Logger, when non-nil, receives a Warn line per denied call with the key,
// tool name, and session ID. When nil, denials are not logged.
Logger *slog.Logger

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix the logger field contract.

The godoc promises key, tool name, and session ID, but the middleware logs only the key and tool name. Prefer updating the comment to avoid promising extra session-ID logging unless you intentionally want that additional identifier in logs.

Also applies to: 209-213

🤖 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 `@server/ratelimit.go` around lines 81 - 83, Update the Logger field contract
in the rate limit middleware so the godoc matches actual behavior: the Logger
comment currently promises key, tool name, and session ID, but the logging path
only includes the key and tool name. Either revise the comment attached to
Logger in ratelimit.go to remove the session ID mention, or, if intended, update
the denied-call logging code in the rate-limit middleware to actually emit the
session ID as well; keep the contract and implementation consistent in both
referenced locations.

Comment thread server/ratelimit.go
Comment on lines +102 to +111
func WithRateLimit(opts RateLimitOpts) ServerOption {
if opts.RPS <= 0 {
panic(fmt.Sprintf("server.WithRateLimit: RPS must be > 0, got %v", opts.RPS))
}
if opts.Burst < 1 {
panic(fmt.Sprintf("server.WithRateLimit: Burst must be >= 1, got %d", opts.Burst))
}
if opts.GlobalRPS > 0 && opts.GlobalBurst < 1 {
panic(fmt.Sprintf("server.WithRateLimit: GlobalBurst must be >= 1 when GlobalRPS > 0, got %d", opts.GlobalBurst))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Reject negative StaleTTL values.

With StaleTTL < 0, sweep computes a future cutoff, so active entries are evicted on every reap cycle and their buckets reset. That weakens rate limiting under misconfiguration.

Proposed fix
 if opts.GlobalRPS > 0 && opts.GlobalBurst < 1 {
 	panic(fmt.Sprintf("server.WithRateLimit: GlobalBurst must be >= 1 when GlobalRPS > 0, got %d", opts.GlobalBurst))
 }
+if opts.StaleTTL < 0 {
+	panic(fmt.Sprintf("server.WithRateLimit: StaleTTL must be >= 0, got %v", opts.StaleTTL))
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func WithRateLimit(opts RateLimitOpts) ServerOption {
if opts.RPS <= 0 {
panic(fmt.Sprintf("server.WithRateLimit: RPS must be > 0, got %v", opts.RPS))
}
if opts.Burst < 1 {
panic(fmt.Sprintf("server.WithRateLimit: Burst must be >= 1, got %d", opts.Burst))
}
if opts.GlobalRPS > 0 && opts.GlobalBurst < 1 {
panic(fmt.Sprintf("server.WithRateLimit: GlobalBurst must be >= 1 when GlobalRPS > 0, got %d", opts.GlobalBurst))
}
func WithRateLimit(opts RateLimitOpts) ServerOption {
if opts.RPS <= 0 {
panic(fmt.Sprintf("server.WithRateLimit: RPS must be > 0, got %v", opts.RPS))
}
if opts.Burst < 1 {
panic(fmt.Sprintf("server.WithRateLimit: Burst must be >= 1, got %d", opts.Burst))
}
if opts.GlobalRPS > 0 && opts.GlobalBurst < 1 {
panic(fmt.Sprintf("server.WithRateLimit: GlobalBurst must be >= 1 when GlobalRPS > 0, got %d", opts.GlobalBurst))
}
if opts.StaleTTL < 0 {
panic(fmt.Sprintf("server.WithRateLimit: StaleTTL must be >= 0, got %v", opts.StaleTTL))
}
🤖 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 `@server/ratelimit.go` around lines 102 - 111, In WithRateLimit, add validation
for RateLimitOpts.StaleTTL so negative values are rejected up front; this
misconfiguration causes sweep to calculate a future cutoff and can evict active
entries repeatedly. Mirror the existing RPS/Burst checks by panicking with a
clear message when opts.StaleTTL < 0, and keep the validation alongside the
other RateLimitOpts guardrails in WithRateLimit.

@tarunbtw

Copy link
Copy Markdown

@ultramcu first time reviewing a PR like this, so take it with that in mind.
Walked through the locking around touch/sweep and it looks like it correctly closes the eviction-race issue the original issue called out. The self-restarting reaper is a nice touch too, stops when idle and restarts on new traffic.
Also like that the second test file was written blind from the spec rather than the implementation.
One question: is reaperStop wired up to anything yet, or is that scaffolding for a future shutdown path?

@ultramcu

Copy link
Copy Markdown
Author

Thanks for the careful read, @tarunbtw — and good eye. You're right: reaperStop wasn't wired to anything. It was scaffolding for an explicit shutdown path, but MCPServer has no shutdown/Close lifecycle to hook it to, and the reaper already self-terminates when the store drains (restarting on new traffic), so that stop channel was a dead exit branch.

I've removed it (8463b74) — reap now just loops on the ticker. Leaner, and one less speculative knob. If a server-lifecycle hook ever lands upstream, re-adding a stop channel is trivial. Appreciate the review!

ultramcu added 3 commits July 9, 2026 19:58
Adds a built-in per-session token-bucket rate limiter as a tool-handler
middleware (golang.org/x/time/rate), with an optional global ceiling,
a pluggable KeyFunc and OnDeny, and a lazy self-terminating reaper for
idle limiters (no background goroutine on an idle server).

Closes mark3labs#896.
The reaper already self-terminates when the store drains and restarts on
new traffic, and nothing closed reaperStop (the MCPServer has no shutdown
lifecycle to wire it to). Remove the speculative stop channel; reap now
loops on the ticker directly.
CI lint/test failures were: (1) the otel submodule (replace => ..) needed
golang.org/x/time added to its go.mod for the new dependency; (2) the
rate-limit tests tripped the repo's modernize + usetesting linters.
Fixes: add the otel go.mod require line, use range-over-int, atomic.Int64
for the flagged counters, drop the redundant loop-var copy, and use
t.Context() in tests. No behavior change.
@ultramcu
ultramcu force-pushed the feat/ratelimit-middleware branch from 8463b74 to 2257324 Compare July 9, 2026 13:07
@ultramcu

ultramcu commented Jul 9, 2026

Copy link
Copy Markdown
Author

Rebased onto latest main and fixed the CI failures:

  • test-otel / lint (otel) — the otel submodule uses replace => .., so the new golang.org/x/time/rate dependency needs to be recorded in otel/go.mod as well. Ran go mod tidy there.
  • lint (.) — the rate-limit tests tripped the repo's modernize + usetesting linters. Switched to range-over-int loops, atomic.Int64 for the two flagged counters, dropped a redundant loop-var copy, and used t.Context(). No behavior change.

Verified locally: golangci-lint run is clean on the package, and go test -race ./... plus the otel module both pass. Ready for a workflow re-run whenever a maintainer can approve it — thanks! 🙏

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.

feature: built-in per-session RateLimit middleware

2 participants