feature: built-in per-session RateLimit middleware
Problem Statement
Every non-trivial MCP server in Go ends up re-implementing per-session rate limiting on top of mcp-go (or the official modelcontextprotocol/go-sdk). The pattern is consistent and well-understood, but each project diverges on small details that bite later — eviction policy, error shape, config surface, where the limiter "key" comes from, and whether config can be swapped at runtime.
Two recent independent implementations in production-grade servers:
hashicorp/terraform-mcp-server—pkg/client/ratelimit.go— usesserver.ToolHandlerMiddleware, exposesRateLimitMiddleware.Middleware(), per-tool + per-session, manualCleanupSessions, returns generic Go errors.containers/kubernetes-mcp-server—pkg/mcp/middleware.go:324+(on top of go-sdk) —rateLimitingMiddleware, per-session map of*rate.Limiter, 10-min staleness reap on a 5-min ticker, runtime config swap, returns&jsonrpc.Error{Code: -32029, Message: "rate limit exceeded"}(the JSON-RPC standard rate-limit code).
Both are ~80–120 LOC of careful concurrent code each, with the same defect class around session-eviction races that's easy to get wrong on the first attempt (eviction-during-active-request is the usual one). A native mcp-go primitive would let server authors take the obvious correct path instead of reinventing it.
Proposed Solution
Add server.WithRateLimit that fits the existing tracer / logger shape:
import (
"github.com/mark3labs/mcp-go/server"
"golang.org/x/time/rate"
)
srv := server.NewMCPServer("…", "…",
server.WithRateLimit(server.RateLimitOpts{
// Per-session limiter (most common case)
PerSessionRPS: rate.Limit(5),
PerSessionBurst: 10,
// Optional global ceiling (back-pressure on busy servers)
GlobalRPS: 0, // disabled when 0
GlobalBurst: 0,
// Eviction — sane defaults so users don't think about it (k8s-mcp pattern)
ReapInterval: 5 * time.Minute,
ReapTTL: 10 * time.Minute,
// Override the key source (default: session ID via ClientSessionFromContext)
KeyFunc: nil,
// What to return on deny — default returns &jsonrpc.Error{Code: -32029}
OnDeny: nil,
}),
)Implementation sketch:
- For tool calls: register a
ToolHandlerMiddleware(the existing primitive). - For non-tool methods: hook
Hooks.OnRequestInitialization(the only hook that can short-circuit), matching howWithTracercovers both surfaces. - Use
golang.org/x/time/rate.Limiter(already widely vendored across the Go ecosystem; no new dependency). - Reaper goroutine lifetime tied to the server lifecycle (channel-close on server stop).
- When integrated with the in-flight
WithLogger(#892) andWithMeter(#893), the middleware emits onemcp.rate_limit.deniedlog line + one counter increment per denial — so observability is consistent without a separate config.
MCP Spec Reference
Remove this section if not applicable.
The MCP spec does not standardise rate limiting; servers are free to enforce it however they like. JSON-RPC 2.0 reserves the -32000..-32099 range for server-defined errors; -32029 "rate limit exceeded" is a community convention (used by both reference implementations above) and would be the proposed default. Callers can override via OnDeny.
Example Usage
The full example above shows the option. A minimal real-world test would look like:
srv := server.NewMCPServer("ratelimited", "0.1.0",
server.WithRateLimit(server.RateLimitOpts{
PerSessionRPS: rate.Limit(2),
PerSessionBurst: 2,
}),
)
srv.AddTool(mcp.NewTool("ping"), pingHandler)
// First two calls within the second succeed; the third returns
// jsonrpc.Error{Code: -32029}.Alternatives/Workarounds Considered
- Status quo (per-project re-impl): works but every new MCP server author reinvents ~100 LOC plus a session-eviction race. Two of the largest production Go MCP servers ship near-identical code already (terraform-mcp, k8s-mcp).
- Per-tool rate limiting only (via existing
ToolHandlerMiddleware): simpler but doesn't solve the "noisy client" problem — one session can still slam multiple tools in parallel. - Token-bucket vs leaky-bucket vs sliding-window: token-bucket (
x/time/rate) is the standard Go pick and what both reference impls use; defer the others until someone asks. - Push it down into the transport layer: HTTP-level rate limiting (e.g. via a reverse proxy) doesn't see
sessionIDand so can't enforce per-session quotas — only per-IP, which is the wrong granularity for MCP-over-stdio and for shared-IP deployments.
Filing as an issue first per the precedent set by WithMeter (#893) and WithLogger (#892) — both mid-flight — so RateLimit can land consistently with that direction (e.g. emitting via WithLogger's slog, counters via WithMeter's meter) instead of conflicting. Happy to send a focused PR (~200 LOC including tests, modeled on the k8s-mcp implementation lifted to a WithRateLimit option) once the API shape is agreed.
Tests can sit on top of mcpharness (the in-process testing toolkit I maintain for mcp-go and go-sdk) so the regression suite covers both adapter paths.
Source: mark3labs/mcp-go