Skip to content

[FEATURE]: Scope per-user rate limits by MCP server in cpex-rate-limiter #172

Description

@jonpspri

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    SHOULDP2: Important but not vital; high-value items that are not crucial for the immediate releaseclient-mrkRequested by Merck clientenhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions