Skip to content

feat(mcp): enforce per-tool rate limits for MCP roles - #1544

Merged
helpcodeai merged 11 commits into
HelpCode-ai:mainfrom
Coding-Professional:feat/983-tool-rate-limits
Nov 6, 2026
Merged

helpcodeai merged 11 commits into
HelpCode-ai:mainfrom
Coding-Professional:feat/983-tool-rate-limits

Conversation

@Coding-Professional

@Coding-Professional Coding-Professional commented Nov 6, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Add optional per-principal minute/hour limits to role tool grants, as agreed in #983, so admins can budget calls to metered APIs and database tools. This PR covers the migration and backend; the role editor UI is a separate follow-up.

Changes

  • Add nullable callsPerMinute and callsPerHour fields with positive-value SQL CHECK constraints documented in Prisma.
  • Add an admin-only configuration endpoint. Replace whitelists in one transaction while preserving retained grant IDs and limits; regenerate the CLI management catalogue.
  • Resolve each window independently across granting roles: the maximum wins and null means unlimited. Unrestricted callers, including workspace ADMINs, receive no per-tool limit.
  • Enforce before cache/engine dispatch in the common executor, including nested Code Mode calls. Refusals return MCP isError with the reached limit and retry time.
  • Admit calls atomically across both Redis windows. Redis outages allow calls with a throttled warning. Without Redis, use bounded process-local counters; capacity pressure allows new calls with a throttled warning and preserves existing budgets. Cloud and self-hosted use the same policy.
  • Cache a bounded workspace-level limit-presence flag for five seconds and coalesce concurrent probes. Cached absence skips membership and per-tool policy queries. Local configuration changes invalidate immediately; other replicas refresh within five seconds. Actual granting-role policies remain fresh.
  • Document these policies and add unit, PostgreSQL and real Redis coverage, including the two requested review fixes. Backend CI starts Redis for the integration tests.

Type

  • New feature
  • Documentation

Testing

Full CI before the latest main sync, on 22c9dfed: Backend CI: 31,820 passed, 1,862 skipped; 1,036 suites passed. Lint, typecheck, build and backend/files-worker boot checks passed. CLI test/build passed on Linux, macOS and Windows; frontend build and E2E passed. CodeQL reports no new alerts. Docker image build and boot/health check passed.

Latest main sync: merged upstream 3ac1d4eb into 396f00a1 without conflicts (two adapter description corrections). From packages/backend, npm test -- --runInBand --runTestsByPath src/adapters/catalog.spec.ts src/adapters/de/weclapp.spec.ts src/adapters/de/weclapp.write-tools.spec.ts src/adapters/de/weclapp.live.spec.ts src/adapters/intl/dynamics365-business-central.live.spec.ts passed: 5 suites, 15,529 tests passed, 5 credential-gated live tests skipped. From the root, node scripts/validate-adapters.mjs passed with the same 1,440 existing warnings; node scripts/generate-llms-txt.mjs --check and git diff --check passed. CI is rerunning on the new merge commit.
Local checks used Windows, Node 24.17.0, PostgreSQL 17 and Redis 7 in throwaway Docker containers. Database/Redis URLs were provided through DATABASE_URL, AUDIT_TEST_DATABASE_URL and TOOL_RATE_LIMIT_TEST_REDIS_URL.

From packages/backend:

  • npx prisma generate, npx prisma validate, npx prisma migrate deploy: passed; all 70 migrations applied to an empty database.
  • npm run lint:check, npx tsc --noEmit -p tsconfig.json, npm run build: passed after the review fixes; lint reports 11 existing warnings.
  • npx jest --runInBand --runTestsByPath src/roles/tool-rate-limits.spec.ts src/roles/roles.service.spec.ts src/roles/tool-rate-limits.postgres.spec.ts src/mcp-server/tool-rate-limit.service.spec.ts src/mcp-server/tool-rate-limit.redis.spec.ts src/mcp-server/dynamic-mcp-tools.rate-limit.spec.ts: 6 suites, 111 tests passed against real PostgreSQL/Redis. Covers cache query counts, concurrent cold probes, invalidation, TTL, tenant isolation, capacity fail-open, atomic admission, expiry, retained grants, cache hits and nested calls.
  • npx jest --runInBand --runTestsByPath src/mcp-server/tool-rate-limit.service.spec.ts: 10 passed after adding warning-throttle assertions.
  • npx jest --runInBand --runTestsByPath src/connectors/engines/database.engine.ssrf.spec.ts: 8 passed after syncing the deterministic upstream DNS test (Make the allowlisted DB host SSRF test independent of DNS #1547).
  • Booted the built backend in self-hosted and Cloud modes against a separate empty migrated database: /health returned HTTP 200 in both. Processes and test containers were stopped afterward.

From the root:

  • npm ci --no-audit --no-fund: passed.
  • npm test -w packages/cli: 23 passed, 3 unavailable-shell skips, with Git Bash on PATH. npm run build -w packages/cli: passed.
  • node scripts/validate-adapters.mjs: passed, with 1,440 existing non-blocking warnings.
  • node scripts/adapter-count.mjs --check, node scripts/check-runtime-deps.mjs, node scripts/generate-cli-operations.mjs --check, node scripts/generate-llms-txt.mjs --check, git diff --check: passed.
  • node --test scripts/generate-llms-txt.test.mjs: 11 passed, 4 Windows file-symlink privilege skips.

Jest reports a worker teardown warning, also present before these review fixes; the suite exits successfully. The full backend suite is verified in CI. A duplicate local full-suite run was stopped after an earlier full CI run passed; the local results above are the focused runs.

  • Added/updated unit tests
  • Tested manually as described above

Checklist

  • My code follows the existing code style
  • I have tested my changes locally
  • I have updated documentation
  • Full CI on latest main sync has completed (previous full CI passed; rerun in progress)
  • This PR has a descriptive title

Related Issues

Relates to #983. Backend scope approved in #983 (comment); the UI remains a follow-up.

@helpcodeai

Copy link
Copy Markdown
Contributor

Thanks @Coding-Professional, this follows what we agreed on #983. I went through the diff and it looks good: every query is scoped to the workspace, the new endpoint is admin only and the DTO is tight.

Two small things before you mark it ready:

  1. The limit check adds one or two DB queries to every tool call, also in workspaces that have no limits at all. Please skip the check when nothing is configured (for example with a cached "has any limit" flag per role).
  2. When the in-memory store is full (installs without Redis), please let the call through and log a warning, instead of refusing it. A full cache should not block tools.

The CodeQL note about hashing apiKeyName is a false positive: it is a key name used in the Redis key, not a secret. You can ignore it.

Mark the PR ready when CI is green and I'll take it from there.

@Coding-Professional
Coding-Professional marked this pull request as ready for review November 6, 2026 09:02
Copilot AI balanced review requested due to automatic review settings November 6, 2026 09:02

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.

@helpcodeai helpcodeai left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @Coding-Professional, both points are done well:

  • The "has any limit" check is cached per workspace for 5 seconds, so workspaces without limits skip the membership and grant queries.
  • A full in-memory store now lets the call through and logs a warning.

The safer setToolAccess is a good extra: it checks the role's workspace and keeps existing grants and their limits.

CI is green. Since this adds a migration, I'll merge it right after today's release, so it ships on its own in the next one.

…ode-ai#983)

Any member can create MCP API keys, so a budget per key name let a
limited user multiply it by creating more keys. The counter is now keyed
on workspace, tool and user, shared by sessions and all API keys.
@helpcodeai

Copy link
Copy Markdown
Contributor

@Coding-Professional, one more thing came up when I tested this locally against a real PostgreSQL and Redis. I pushed a small fix to your branch (292c7ef), so you don't need to do anything.

The counter was keyed on the API-key name, the same identity as the principal limiter. Any member can create MCP API keys, so a user with 10 calls per minute could create five keys and make 50. The counter is now keyed on workspace, tool and user. A user has one budget per tool, shared by their sessions and all of their keys. The service spec, the endpoint description and the API reference say so too.

Everything else held up in my tests:

  • the 4th call over a 3/min limit is refused;
  • admins are not limited;
  • a limit stays in its own workspace;
  • an admin of another workspace cannot change roles, grants or limits here;
  • system roles cannot be changed;
  • clearing a limit takes effect at once.

I'll merge once CI is green on the new commit.

@helpcodeai
helpcodeai merged commit a51cf90 into HelpCode-ai:main Nov 6, 2026
16 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Nov 6, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants