#896·mcp-go

feature: built-in per-session RateLimit middleware

Author: ultramcuCreated May 27, 2026Updated Jun 28, 2026

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-serverpkg/client/ratelimit.go — uses server.ToolHandlerMiddleware, exposes RateLimitMiddleware.Middleware(), per-tool + per-session, manual CleanupSessions, returns generic Go errors.
  • containers/kubernetes-mcp-serverpkg/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:

go
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 how WithTracer covers 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) and WithMeter (#893), the middleware emits one mcp.rate_limit.denied log 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:

go
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 sessionID and 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.