Skip to content

feat: enforce per-caller rate limits and payload budgets in the MCP layer #600 #601 - #669

Open
kryachkow wants to merge 6 commits into
developmentfrom
600-601-mcp-tool-rate-limits-and-payload-budgets
Open

kryachkow wants to merge 6 commits into
developmentfrom
600-601-mcp-tool-rate-limits-and-payload-budgets

Conversation

@kryachkow

@kryachkow kryachkow commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Applicable issues

Description of changes

Marketplace guidance requires MCP tools that are expensive to serve or externally reachable to have rate limits, and requires tool results to stay within the host's payload caps. This adds both guards inside the StatGPT MCP tool wrapper (not DIAL Core), so they can key off the validated bearer token and skip app-only tools.

Per-caller rate limiting (#600)

  • Token-bucket limiter keyed by (caller, cost class), giving both a burst allowance (capacity) and a sustained rate (refill), enforced before the LLM-backed guardrail and execution so an over-limit caller is rejected without spending that work.
  • Each tool declares a cost class: data query and web/RAG tools are expensive, glossary lookups are cheap, and upstream metadata reads keep the moderate default.
  • The caller id is derived from the bearer token (or DIAL API key) with Python's builtin hash (a per-process-seeded SipHash): the raw secret is never used as a dict key or logged, and a high-entropy token cannot be recovered from the id. Unidentifiable callers share one anonymous bucket. A cryptographic hash is avoided on purpose here — CodeQL flags any fast crypto hash of a credential as weak, and its only accepted alternative (a computationally expensive KDF) would itself be a CPU-exhaustion vector, since the id is derived on every call ahead of the rate-limit check.
  • Counters are in-process: limits are per replica and approximate under horizontal scaling, which is enough to stop a single caller from hammering one instance. Idle buckets are pruned once the table grows large.
  • On exhaustion the tool raises an actionable ToolError telling the model how long to wait, rather than a bare 429.

Payload budget (#601)

  • Measures the serialized result and, when over budget, first drops the heavy embedded resources (CSV/Markdown tables) and leaves the compact structuredContent plus a note; if it still does not fit, it returns an actionable error asking the caller to narrow the query.
  • Structured content is measured as compact UTF-8 JSON so non-ASCII text in a multi-language deployment is not over-counted as escaped \uXXXX sequences.

Both guards are configurable via MCP_* env vars (enable flags, window, per-class allowances, max chars), documented in the app README, and exempt app-only tools, whose results are consumed by the application rather than loaded into an agent's context. Covered by unit tests for the limiter, the budget, per-tool cost classes, and the wrapper guards.

Checklist

By submitting this pull request, I confirm that my contribution is made under the terms of the MIT license.

…ayer #600 #601

Add in-process guards that run inside the MCP tool wrapper (not DIAL Core):
- Per-caller rate limiting keyed by a tool cost class (cheap/moderate/
  expensive), enforced before the LLM-backed guardrail and execution so an
  over-limit caller is rejected without spending that work.
- A payload budget that keeps each tool result within the host's size limit.

Cost classes are assigned per tool: data query and web/RAG tools are
expensive, glossary lookups are cheap, and upstream metadata reads keep the
moderate default. App-only tools are exempt from both guards, since their
results are consumed by the application rather than loaded into an agent's
context.

Measure structured content as compact UTF-8 JSON so non-ASCII text in a
multi-language deployment is not over-counted as escaped sequences.
Comment thread statgpt/app/mcp/rate_limit.py Fixed


CodeQL (py/weak-sensitive-data-hashing) flagged hashing the bearer/API
token with plain SHA-256 as weak hashing of sensitive data. Switch to an
HMAC keyed with a per-process random key: a keyed construction CodeQL
accepts that also prevents brute-forcing a leaked digest back to the raw
token. The key is per process because rate-limit buckets already live in
process, so it never needs to outlive the process or match a replica.
Comment thread statgpt/app/mcp/rate_limit.py Fixed
…ol-rate-limits-and-payload-budgets

# Conflicts:
#	statgpt/app/mcp/tools/base.py
CodeQL (py/weak-sensitive-data-hashing) still flagged the keyed HMAC-SHA256
as weak hashing of a credential: it rejects any fast crypto hash of a
credential and accepts only a computationally expensive KDF. Putting a KDF
here would be worse than the "risk" it flags, since caller_identity runs on
every tool call ahead of the rate-limit check and would become a
CPU-exhaustion vector.

Derive the id with Python's builtin hash (a per-process-seeded SipHash)
instead: cheap, non-reversible for a high-entropy token, never holds or logs
the raw token, and not a crypto-API sink CodeQL flags. Drop the now-unused
hashlib/hmac/secrets imports and _CALLER_ID_KEY.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants