Skip to content

Tool-level rate limiting per role #983

Closed

Description

@helpcodeai

An MCP role can already be restricted to a set of tools. It cannot yet be restricted in how often it calls them, which matters most for the two cases where a runaway agent is expensive: metered upstream APIs, and database connectors.

Scope

  • Per-role, per-tool limit (calls per minute / per hour), stored alongside the existing role tool whitelist.
  • Enforced in the MCP tool handler, before the upstream call.
  • Over-limit returns a proper MCP error the agent can read ("rate limit for this tool: N/min, retry in Xs"), not a generic failure.
  • Counted in Redis when REDIS_URL is set, in-process otherwise, and the in-process path must be honest about not being shared across replicas.

Where to look

  • packages/backend/src/mcp-server/mcp-endpoint.controller.ts — planToolSet builds the handler each tool is registered with.
  • The role/tool-whitelist model for where the limit belongs.

Activity

  1. added
    help wantedExtra attention is needed
    roadmapOn the published roadmap; exempt from the stale bot
    on Oct 10, 2026
  2. Coding-Professional commented on Nov 2, 2026

    @Coding-Professional
    Collaborator

    Hi @helpcodeai, I'd be interested in owning this, with the scope agreed before implementation.

    I checked current main: the principal-level limiter is separate from the role/tool budgets described here. I'd propose two focused PRs:

    1. Backend first: optional minute/hour limits on the role/tool mapping, a migration and additive configuration API, tenant-scoped enforcement before upstream execution, atomic Redis admission/expiry, and a bounded per-process fallback when Redis is not configured. Return a readable MCP tool error with retry information. Keep the existing principal limiter and behavior for unconfigured limits unchanged. Cover per-server/shared MCP entry points and nested Code Mode calls where applicable, with concurrency, expiry and tenant-isolation tests and Cloud/self-hosted documentation.
    2. Configuration UI: add limit controls to the role/tool editor once the backend policy is settled.

    Three policy decisions I'd like to confirm first:

    • Should each role/tool budget be shared across all members of the role, or be per user within that role?
    • When several roles grant the same tool, should quota follow the least restrictive granting role, or must the call satisfy every applicable role budget?
    • If Redis is configured but unavailable, should limited calls fail closed? I'd avoid silently replacing a shared budget with independent per-process counters.

    Would this split work? Once the policy is agreed, I'd be happy to take the assignment.

  3. helpcodeai commented on Nov 2, 2026

    @helpcodeai
    ContributorAuthor

    @Coding-Professional thanks, the split works: backend first, then the editor UI. It changes the Prisma schema, so please post the model change here before writing the rest, like #988 did.

    The three policy points:

    1. Per principal, not shared across the role. Use the same bucket as McpPrincipalRateLimitGuard: the user, or the user plus API-key name. One person's runaway agent should not lock out the rest of the team.
    2. Least restrictive granting role. Tool access is already the union of a user's roles (mcpRoleIds), so the limit follows the same rule. A granting role with no limit set means no per-tool limit for that call.
    3. Redis configured but unavailable: fail open with a warning, the same as the principal limiter does today. Don't fall back to per-process counters in that case, as you suggested. The in-process counter is only for installs with no Redis at all, and the docs should say plainly that it isn't shared across replicas.

    Nested Code Mode calls count against the same budget. The over-limit error should name the tool, the limit and the retry time. I'm assigning you.

  4. Coding-Professional commented on Nov 5, 2026

    @Coding-Professional
    Collaborator

    Thanks @helpcodeai. I'll follow the agreed policies. Here is the proposed model change before implementing the backend:

    model ToolRoleAccess {
      id     String  @id @default(cuid())
      roleId String  @map("role_id")
      role   Role    @relation(fields: [roleId], references: [id], onDelete: Cascade)
      toolId String  @map("tool_id")
      tool   McpTool @relation(fields: [toolId], references: [id], onDelete: Cascade)
    
      /// Null means no limit for this window; configured values must be positive.
      callsPerMinute Int? @map("calls_per_minute")
      callsPerHour   Int? @map("calls_per_hour")
    
      @@unique([roleId, toolId])
      @@map("tool_role_access")
    }
    • The migration adds two nullable integer columns and SQL CHECK constraints (IS NULL OR > 0). No backfill, new table or additional index: existing grants remain unlimited, and the existing unique key covers configuration lookups. Request DTOs also reject zero, negative and non-integer values; null explicitly clears a limit.
    • These rows store policy only. Usage counters stay outside Postgres, keyed by organization, tool ID and the principal bucket from McpPrincipalRateLimitGuard, shared across MCP entry points and nested Code Mode calls. No role ID in the counter key, so changing the granting role does not create another allowance.
    • For multiple granting roles, I propose resolving each window independently: the largest configured limit wins, and a null limit makes that window unlimited. Both resulting windows must admit the call. A role with both fields null therefore removes the per-tool limit, as agreed.
    • Configuration will be additive to the current API. The existing PUT /api/roles/:id/tools will preserve limits for retained tool IDs rather than deleting and recreating every grant; newly added grants start unlimited. A separate admin endpoint can update or clear limits on an existing grant, with organization checks and the existing system-role restriction.
    • Redis admission for the applicable windows will be atomic. Configured-but-unavailable Redis fails open with a warning, without switching to local counters. Only installs without Redis use bounded process-local counters. Cloud and self-hosted use the same policy and enforcement code.

    Validation: applied this model to the full schema from current main (a810004f) in a scratch file and ran node node_modules/prisma/build/index.js validate --schema .git/983-schema-proposed.prisma: passed. No application code or database has been changed yet.

    Does this model and the per-window interpretation of least restrictive look right? I'll proceed with the migration, backend enforcement and tests once confirmed; the editor UI stays in the second PR.

  5. helpcodeai commented on Nov 5, 2026

    @helpcodeai
    ContributorAuthor

    @Coding-Professional the model looks right, go ahead with the migration and the backend.

    On the details:

    • Per-window resolution: yes. Resolve each window on its own: the highest configured value wins, and null means no limit. Two roles can combine into something looser than either one alone, for example 100/min with 200/h plus 10/min with 10,000/h. That's acceptable, but write the rule in the docs and in the spec so admins can predict it.
    • Unrestricted callers. getAllowedToolIds returns null for an org ADMIN, and for a member with no role in an org that uses no whitelists. These callers get no per-tool limit. Only the principal limiter applies to them. Please cover this in a test.
    • PUT /api/roles/:id/tools. Today it deletes every grant and recreates them. Keep the new version in one transaction: delete the removed IDs, create the added ones, and leave the retained rows untouched.
    • CHECK constraints are fine in the SQL migration. Prisma doesn't model them, so mention them in a comment on the two fields. Then nobody drops them by accident.

    Thanks for the careful write-up.

  6. helpcodeai commented on Nov 7, 2026

    @helpcodeai
    ContributorAuthor

    Shipped in #1544, live on Cloud since 6 November. Thanks again @Coding-Professional for building it.

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

Metadata

Metadata

Labels

help wantedExtra attention is neededroadmapOn the published roadmap; exempt from the stale bot

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions