Summary
Upstream requirement tracked in IBM/mcp-context-forge#6230.
ContextForge gateway operators need the RateLimiterPlugin (cpex-rate-limiter) to track per-user quotas independently per MCP server, so that exhausting a user's budget on MCP-A does not block that user from reaching MCP-B.
Current behaviour
The Rust engine's check() / check_async() accept user, tenant, and tool as dimensions. The resulting Redis key shape is effectively:
{prefix}:user:{user_id}:{window}
Exhausting this bucket blocks the user across all MCP servers simultaneously.
Required behaviour
Add a stable server_id dimension so the bucket key becomes:
{prefix}:user:{user_id}:server:{server_id}:{window}
A user-facing user_scope config knob (e.g. user_scope: "server") should opt in to this behaviour, with the current global-user scope remaining the default for backward compatibility:
by_user: "60/m"
user_scope: "server" # new; default: "global" (existing behaviour)
Acceptance criteria
Scenario: Exhausting one MCP server does not consume another server's quota
Given RateLimiterPlugin is configured with user_scope: server and by_user: X/m
When User-1 invokes Tool-A on MCP-A X times within the current minute
Then the next invocation by User-1 on MCP-A is rejected with RATE_LIMIT
When User-1 invokes Tool-B on MCP-B during that same minute
Then the MCP-B invocation succeeds
Scenario: Backward-compatible default
Given RateLimiterPlugin is configured WITHOUT user_scope (or user_scope: global)
Then behaviour is identical to cpex-rate-limiter <= 0.1.8
Implementation notes
- Key on the catalog UUID (not name/slug) to avoid quota resets on server rename.
- Document that
by_user: X/m with user_scope: server means X/m per server, so a user with access to N servers has an effective ceiling of N×X/m; by_tenant becomes the primary global control.
- At rollout, changing the Redis key shape orphans all in-flight fixed-window counters. Consider reading both key shapes for one window length during migration, or document the cut-over impact clearly.
- The
context_prefix parameter on RateLimiterEngine.check() handles tenant/team namespace isolation; server_id is a separate, orthogonal dimension.
- Both
check() and check_async() (sync/memory and async/Redis paths) need the new parameter.
Affected components
plugins/rust/python-package/cpex-rate-limiter/ — Rust engine (RateLimiterEngine, EngineConfig), Python shim (RateLimiterPluginCore), and stub (.pyi)
plugin-manifest.yaml — add user_scope to default_configs
- Tests covering the new key shape (memory and Redis backends) and backward-compatibility
References
Summary
Upstream requirement tracked in IBM/mcp-context-forge#6230.
ContextForge gateway operators need the
RateLimiterPlugin(cpex-rate-limiter) to track per-user quotas independently per MCP server, so that exhausting a user's budget on MCP-A does not block that user from reaching MCP-B.Current behaviour
The Rust engine's
check()/check_async()acceptuser,tenant, andtoolas dimensions. The resulting Redis key shape is effectively:Exhausting this bucket blocks the user across all MCP servers simultaneously.
Required behaviour
Add a stable
server_iddimension so the bucket key becomes:A user-facing
user_scopeconfig knob (e.g.user_scope: "server") should opt in to this behaviour, with the current global-user scope remaining the default for backward compatibility:Acceptance criteria
Implementation notes
by_user: X/mwithuser_scope: servermeans X/m per server, so a user with access to N servers has an effective ceiling of N×X/m;by_tenantbecomes the primary global control.context_prefixparameter onRateLimiterEngine.check()handles tenant/team namespace isolation;server_idis a separate, orthogonal dimension.check()andcheck_async()(sync/memory and async/Redis paths) need the new parameter.Affected components
plugins/rust/python-package/cpex-rate-limiter/— Rust engine (RateLimiterEngine,EngineConfig), Python shim (RateLimiterPluginCore), and stub (.pyi)plugin-manifest.yaml— adduser_scopetodefault_configsReferences