Skip to content

Commit f13cb22

Browse files
feat(security): per-token call rate limit for agent tokens (#1746)
## Description Closes #1699. Adds a per-token call rate limit for agent tokens, built on the plan in the maintainer's comment on the issue. - **Where it is enforced.** At the managed-client admission seam (`acquireAdmission`, before the concurrency registry), so it covers `call_tool_*`, direct routing mode, `code_execution` (each call a script makes counts) and the REST tool-call path with one check. `ListTools` and `Ping` are not counted, same as the concurrency limits. - **The limit.** A token bucket per agent token: N calls per minute plus a burst. Off by default (absent or `per_minute: 0`). The limiter is in the repo (`internal/upstream/limiter/token_rate.go`, injectable clock), no new dependency. State is in memory, keyed by owner + token name (names are only unique per owner); idle buckets are swept. A restart refills every bucket. - **Agent tokens only.** The check runs only for `AuthTypeAgent` callers with a token name. The admin API key, the Unix socket and unauthenticated callers go through unchanged. - **Config.** `agent_token_rate_limit: {per_minute, burst}` is the default for every agent token. Hot-reloadable (goes through `applyConcurrencyLimits`; added to `undiffedHotConfigFields`). Negative values, values over 100000 and a `burst` without a `per_minute` are validation errors that name the field. - **Per token.** `mcpproxy token create --rate-limit N [--rate-burst M]` and `rate_limit` on `POST /api/v1/tokens` store a budget on the token. It replaces the config default for that token; `per_minute: 0` exempts it. `GET /api/v1/tokens[/{name}]` and `token show` return it. The record gets one new omitempty field, so no storage migration. - **Refusal.** A new `LimitError` reason `rate_limited` with scope `token` goes through the existing shed plumbing instead of a parallel path (a `Shed()` predicate replaces the hard-coded `queue_full || queue_timeout` checks): - MCP: an `isError` tool result. - REST: `429` with `Retry-After` (seconds, rounded up, minimum 1). - Message: "Rate limit reached: this agent token may make N tool calls per minute (bursts of up to M) and has used its allowance. Retry in S second(s)." - A refused call takes no concurrency slot and never reaches the upstream. - **Record.** The refusal is one rejected `tool_call` in the activity log with reason `rate_limited`, written by the same observer as the concurrency sheds, so there is exactly one row per refusal and no second authz line. It carries the token name, prefix and client id (the observer now passes the attribution through). The audit-line `reason` vocabulary gains `rate_limited`, in both schema copies. - **Metric.** `mcpproxy_tool_calls_rejected_total{reason="rate_limited",scope="token"}` (existing counter, new label values). - Docs: `docs/features/agent-tokens.md` (Call Rate Limit), `docs/configuration.md`, `docs/configuration/config-file.md`, `docs/features/audit-log.md`, the spec 107 audit-line contract, CHANGELOG. OAS regenerated with `make swagger` (the diff is only the new config type and the two 429 descriptions). Choices I made that you may want different: - A token's own budget replaces the config default rather than stacking on it, and `0` is an explicit exemption. That gives a "no limit for this one batch token" without turning the default off for everyone. Say if you want the stricter of the two instead. - There is no way to change a budget on an existing token (there is no token update route today). Re-create the token, or change the config default. - Changing a budget keeps the calls a token has already spent (clamped to the new size); it does not refund them. - `burst` defaults to `per_minute`, so `per_minute: 60` alone allows 60 calls at once, then one a second. - `reason: rate_limited` is added to the shared `RejectedCount` in the activity summary, so it counts alongside the concurrency sheds. Say if you want it separate. - Out of scope, as agreed on the issue: a tighter write/destructive cap, a daily budget, and a per-token metric label (token names would be unbounded cardinality, so the metric stays `scope="token"` without the name; the name is in the activity record). ## Testing - [x] I have tested these changes locally - [x] I have added/updated tests that prove my fix is effective or my feature works - [x] All existing tests pass (see below) - `internal/upstream/limiter`: bucket math with a fake clock (burst allowed, then refused with the exact `RetryAfter`, refill, per-key isolation, per-token override and exemption, changed budget, sweep, concurrent callers), error text and `Shed()`. - `internal/upstream/managed`: enforcement at the seam: burst then refusal with `Retry-After`, refill, per-token isolation, off by default, admin and unauthenticated callers unaffected, a refused call takes no concurrency slot. - `internal/server`: end to end against a counting upstream through `call_tool_read`, `code_execution`, direct mode and the REST typed error. The upstream is never reached after the budget is spent. - `internal/runtime`: the rejected activity record names the token; a concurrency shed stays unattributed; a real runtime with a live HTTP upstream, the metric sink and a config reload; `IssueToken` validation and storage. - `internal/httpapi`: `rate_limit` on create / get / list, validation, exemption, and the 429 + `Retry-After` mapping (1.5s becomes 2). - `internal/config`, `internal/auth`, `internal/audit`, `internal/upstream`, `cmd/mcpproxy`: validation, JSON round trip, schema vocabulary, manager default follows reloads, CLI flags and output. `go build ./...`, `go build -tags server ./...`, `go vet ./...` and `go vet -tags server ./...` are clean. `golangci-lint` v2.9.0 with `.github/.golangci.yml` reports 0 issues, bare and with `--build-tags server`. `go test -race -skip "E2E|Binary|MCPProtocol|TestInfoEndpoint|TestGracefulShutdownNoPanic|TestSocketInfoEndpoint" ./...` passes, as does the server-tag CI command for `./internal/{serveredition,config,oauth,server,httpapi,storage}/...` and `-shuffle=on` on the packages above.
1 parent 0cfb7eb commit f13cb22

49 files changed

Lines changed: 2189 additions & 74 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ Releases follow [Semantic Versioning](https://semver.org/).
9191

9292
### Features
9393

94+
- **agent tokens: per-token call rate limit (#1699).** Cap how many upstream tool calls one agent token may make per minute, so a runaway agent loop cannot burn through an upstream's quota. Off by default. `agent_token_rate_limit` (`per_minute`, optional `burst`) sets a default for every agent token and is live-reloadable; `mcpproxy token create --rate-limit N [--rate-burst M]` (or `rate_limit` on `POST /api/v1/tokens`) gives one token its own budget, and `--rate-limit 0` exempts it. It is enforced where the concurrency limits are, so it covers `call_tool_*`, direct routing mode, `code_execution` and the REST tool-call endpoint, and never applies to the admin API key or unauthenticated local callers. A refused call never reaches the upstream: MCP callers get an error result, REST callers get `429` with `Retry-After`, the activity log records a `tool_call` with status `rejected`, reason `rate_limited` and the token's name, and `mcpproxy_tool_calls_rejected_total` counts it with `reason="rate_limited"`, `scope="token"`. The audit-line `reason` vocabulary gains `rate_limited`. Buckets are in memory, so a restart refills them.
9495
- **profiles:** `mcpproxy access suggest` turns the calls your profiles blocked into reviewed profile
9596
changes (#1700). It reads the profile refusals in the audit log (`--since`, default 24h), groups them
9697
by profile, tool and block reason, re-checks each group with the access explainer so tools that are

‎cmd/mcpproxy/token_cmd.go‎

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ var (
2626
tokenExpires string
2727
tokenProfilePin string
2828
tokenProfile string
29+
// tokenRateLimit / tokenRateBurst are the token's own call budget (issue
30+
// #1699). Only sent when the flag was given, so an omitted --rate-limit
31+
// leaves the token on the agent_token_rate_limit default.
32+
tokenRateLimit int
33+
tokenRateBurst int
2934

3035
// token list filters (Spec 108-g, FR-031)
3136
tokenListProfile string
@@ -82,7 +87,14 @@ without --profile at least one of them is required.
8287
Examples:
8388
mcpproxy token create --name ci --profile work-readonly
8489
mcpproxy token create --name deploy-bot --servers github,gitlab --permissions read,write
85-
mcpproxy token create --name ci-agent --servers "*" --permissions read --expires 7d`,
90+
mcpproxy token create --name ci-agent --servers "*" --permissions read --expires 7d
91+
mcpproxy token create --name loop-guard --profile work-readonly --rate-limit 60 --rate-burst 10
92+
93+
--rate-limit caps how many upstream tool calls the token may make: that many a
94+
minute on average, --rate-burst at once (default: the same number). A call over
95+
the budget is refused with a rate_limited error and a retry hint, and is
96+
recorded as a rejected call. 0 exempts the token from the agent_token_rate_limit
97+
default in the config; leave the flag out to follow that default.`,
8698
RunE: runTokenCreate,
8799
}
88100

@@ -91,6 +103,8 @@ Examples:
91103
cmd.Flags().StringVar(&tokenServers, "servers", "", "Legacy scope: comma-separated allowed server names, or \"*\" for all (use --profile instead)")
92104
cmd.Flags().StringVar(&tokenPermissions, "permissions", "", "Legacy scope: comma-separated permission tiers read, write, destructive (use --profile instead)")
93105
cmd.Flags().StringVar(&tokenExpires, "expires", "30d", "Token expiry duration (e.g., 7d, 30d, 90d, 365d)")
106+
cmd.Flags().IntVar(&tokenRateLimit, "rate-limit", 0, "Limit this token to N upstream tool calls per minute (0 = no limit, overriding the config default)")
107+
cmd.Flags().IntVar(&tokenRateBurst, "rate-burst", 0, "Calls this token may make at once after a quiet spell (default: the --rate-limit value)")
94108
cmd.Flags().StringVar(&tokenProfilePin, "profile-pin", "", "Deprecated alias of --profile")
95109
_ = cmd.Flags().MarkHidden("profile-pin")
96110
_ = cmd.MarkFlagRequired("name")
@@ -170,7 +184,7 @@ func newTokenCLIClient() (*cliclient.Client, *config.Config, error) {
170184
return client, cfg, nil
171185
}
172186

173-
func runTokenCreate(_ *cobra.Command, _ []string) error {
187+
func runTokenCreate(cmd *cobra.Command, _ []string) error {
174188
// Validate locally before any request (exit 1, nothing sent).
175189
profileName := tokenProfile
176190
if tokenProfilePin != "" {
@@ -188,6 +202,15 @@ func runTokenCreate(_ *cobra.Command, _ []string) error {
188202
fmt.Fprintln(os.Stderr, "hint: consider --profile <name> instead of --servers/--permissions (legacy scope)")
189203
}
190204

205+
var limitSet, burstSet bool
206+
if cmd != nil {
207+
limitSet, burstSet = cmd.Flags().Changed("rate-limit"), cmd.Flags().Changed("rate-burst")
208+
}
209+
rateLimit, err := tokenRateLimitFromFlags(limitSet, burstSet)
210+
if err != nil {
211+
return err
212+
}
213+
191214
client, _, err := newTokenCLIClient()
192215
if err != nil {
193216
return err
@@ -208,6 +231,9 @@ func runTokenCreate(_ *cobra.Command, _ []string) error {
208231
if tokenPermissions != "" {
209232
body["permissions"] = splitAndTrim(tokenPermissions)
210233
}
234+
if rateLimit != nil {
235+
body["rate_limit"] = rateLimit
236+
}
211237

212238
bodyJSON, err := json.Marshal(body)
213239
if err != nil {
@@ -257,10 +283,55 @@ func runTokenCreate(_ *cobra.Command, _ []string) error {
257283
fmt.Printf(" Profile: %s\n", pin)
258284
}
259285
printField(" Expires: ", result, "expires_at")
286+
if line := formatTokenRateLimit(result["rate_limit"]); line != "" {
287+
fmt.Printf(" Rate limit: %s\n", line)
288+
}
260289

261290
return nil
262291
}
263292

293+
// tokenRateLimitFromFlags builds the rate_limit request member from the
294+
// --rate-limit / --rate-burst flags, or nil when neither was given. It checks
295+
// the numbers locally so a typo exits 1 before anything is sent.
296+
func tokenRateLimitFromFlags(limitSet, burstSet bool) (map[string]int, error) {
297+
if !limitSet && !burstSet {
298+
return nil, nil
299+
}
300+
if tokenRateLimit < 0 {
301+
return nil, newFlagValidationError("--rate-limit cannot be negative: use 0 for no limit")
302+
}
303+
if tokenRateBurst < 0 {
304+
return nil, newFlagValidationError("--rate-burst cannot be negative")
305+
}
306+
if burstSet && tokenRateLimit == 0 {
307+
return nil, newFlagValidationError("--rate-burst needs --rate-limit above 0")
308+
}
309+
out := map[string]int{"per_minute": tokenRateLimit}
310+
if tokenRateBurst > 0 {
311+
out["burst"] = tokenRateBurst
312+
}
313+
return out, nil
314+
}
315+
316+
// formatTokenRateLimit renders a token's rate_limit response member, or "" when
317+
// the token has none of its own.
318+
func formatTokenRateLimit(v interface{}) string {
319+
m, ok := v.(map[string]interface{})
320+
if !ok {
321+
return ""
322+
}
323+
perMinute, _ := m["per_minute"].(float64)
324+
burst, _ := m["burst"].(float64)
325+
switch {
326+
case perMinute <= 0:
327+
return "none (exempt from the config default)"
328+
case burst > 0 && burst != perMinute:
329+
return fmt.Sprintf("%d calls/minute, bursts of up to %d", int(perMinute), int(burst))
330+
default:
331+
return fmt.Sprintf("%d calls/minute", int(perMinute))
332+
}
333+
}
334+
264335
// printTokenStructured prints a token response as json or yaml.
265336
func printTokenStructured(result map[string]interface{}) error {
266337
formatter, err := GetOutputFormatter()
@@ -428,6 +499,9 @@ func runTokenShow(_ *cobra.Command, args []string) error {
428499
if revoked, ok := result["revoked"].(bool); ok {
429500
fmt.Printf("Revoked: %v\n", revoked)
430501
}
502+
if line := formatTokenRateLimit(result["rate_limit"]); line != "" {
503+
fmt.Printf("Rate limit: %s\n", line)
504+
}
431505
printField("Created: ", result, "created_at")
432506
printField("Expires: ", result, "expires_at")
433507
if lastUsed := getMapString(result, "last_used_at"); lastUsed != "" {

‎cmd/mcpproxy/token_cmd_profile_test.go‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,3 +171,56 @@ func TestTokenShow_LegacyScopeHintAndClientKind(t *testing.T) {
171171
require.Contains(t, out, "Manage with: mcpproxy client show cursor")
172172
require.NotContains(t, out, "Legacy scope")
173173
}
174+
175+
// Issue #1699: --rate-limit / --rate-burst set the token's own call budget.
176+
177+
func TestTokenCreate_RateLimitFlags(t *testing.T) {
178+
const withLimit = `{"name":"ci","token":"mcp_agt_0123456789abcdef","allowed_servers":["*"],"permissions":["read"],"expires_at":"2026-10-30T00:00:00Z","rate_limit":{"per_minute":60,"burst":10}}`
179+
rec := newTokenRecorder(t, map[string]cannedResponse{"POST /api/v1/tokens": createdResp(withLimit)})
180+
out, _, err := runCLI(t, tokenCommandForTest, "table", "create", "--name", "ci", "--profile", "work-ro2", "--rate-limit", "60", "--rate-burst", "10")
181+
require.NoError(t, err)
182+
body := rec.only(t).jsonBody(t)
183+
require.Equal(t, map[string]any{"per_minute": float64(60), "burst": float64(10)}, body["rate_limit"])
184+
require.Contains(t, out, "Rate limit: 60 calls/minute, bursts of up to 10")
185+
}
186+
187+
func TestTokenCreate_RateLimitOmittedByDefault(t *testing.T) {
188+
rec := newTokenRecorder(t, map[string]cannedResponse{"POST /api/v1/tokens": createdResp(createdToken)})
189+
out, _, err := runCLI(t, tokenCommandForTest, "table", "create", "--name", "ci", "--profile", "work-ro2")
190+
require.NoError(t, err)
191+
require.NotContains(t, rec.only(t).jsonBody(t), "rate_limit", "no flag, no member: the config default applies")
192+
require.NotContains(t, out, "Rate limit:")
193+
}
194+
195+
func TestTokenCreate_RateLimitZeroIsSentAsAnExemption(t *testing.T) {
196+
rec := newTokenRecorder(t, map[string]cannedResponse{"POST /api/v1/tokens": createdResp(createdToken)})
197+
_, _, err := runCLI(t, tokenCommandForTest, "table", "create", "--name", "ci", "--profile", "work-ro2", "--rate-limit", "0")
198+
require.NoError(t, err)
199+
require.Equal(t, map[string]any{"per_minute": float64(0)}, rec.only(t).jsonBody(t)["rate_limit"])
200+
}
201+
202+
func TestTokenCreate_RateLimitRefusedLocally(t *testing.T) {
203+
for name, args := range map[string][]string{
204+
"negative rate": {"--rate-limit", "-5"},
205+
"negative burst": {"--rate-limit", "5", "--rate-burst", "-1"},
206+
"burst without rate": {"--rate-burst", "5"},
207+
"burst with zero rate": {"--rate-limit", "0", "--rate-burst", "5"},
208+
} {
209+
t.Run(name, func(t *testing.T) {
210+
rec := newTokenRecorder(t, map[string]cannedResponse{})
211+
_, _, err := runCLI(t, tokenCommandForTest, "table", append([]string{"create", "--name", "ci", "--profile", "p"}, args...)...)
212+
require.Error(t, err)
213+
require.Equal(t, ExitCodeGeneralError, classifyError(err))
214+
require.Empty(t, rec.all(), "a bad number exits before anything is sent")
215+
})
216+
}
217+
}
218+
219+
func TestFormatTokenRateLimit(t *testing.T) {
220+
require.Equal(t, "", formatTokenRateLimit(nil))
221+
require.Equal(t, "", formatTokenRateLimit("junk"))
222+
require.Equal(t, "none (exempt from the config default)", formatTokenRateLimit(map[string]interface{}{"per_minute": float64(0)}))
223+
require.Equal(t, "30 calls/minute", formatTokenRateLimit(map[string]interface{}{"per_minute": float64(30)}))
224+
require.Equal(t, "30 calls/minute", formatTokenRateLimit(map[string]interface{}{"per_minute": float64(30), "burst": float64(30)}))
225+
require.Equal(t, "30 calls/minute, bursts of up to 5", formatTokenRateLimit(map[string]interface{}{"per_minute": float64(30), "burst": float64(5)}))
226+
}

‎docs/configuration.md‎

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -338,7 +338,7 @@ saturated upstream. The separate-process CLI debug client is out of scope.
338338

339339
| Metric | Type | Labels | Meaning |
340340
|--------|------|--------|---------|
341-
| `mcpproxy_tool_calls_rejected_total` | counter | `server`, `reason`, `scope` | Calls shed by a limit. `reason` = `queue_full` \| `queue_timeout`; `scope` = `server` \| `global`. `server` is the call's target even for a global shed. |
341+
| `mcpproxy_tool_calls_rejected_total` | counter | `server`, `reason`, `scope` | Calls shed by a limit. `reason` = `queue_full` \| `queue_timeout` \| `rate_limited`; `scope` = `server` \| `global` \| `token`. `server` is the call's target even for a global or token shed. |
342342
| `mcpproxy_concurrency_active` | gauge | `scope`, `server` | Calls currently holding a slot. |
343343
| `mcpproxy_concurrency_queue_depth` | gauge | `scope`, `server` | Calls currently waiting for a slot. |
344344

@@ -350,6 +350,44 @@ per shed — including the ones from `code_execution` and replay: the rejection
350350
recorded at the limiter, below the MCP dispatch layer, so no origin can bypass
351351
it and none can double-report it.
352352

353+
### Agent Token Call Rate Limit
354+
355+
Caps how many upstream tool calls one [agent token](features/agent-tokens.md)
356+
may make per minute, so a runaway agent loop cannot burn through an upstream's
357+
quota (GitHub [#1699](https://github.com/smart-mcp-proxy/mcpproxy-go/issues/1699)).
358+
**Off by default.**
359+
360+
```json
361+
{
362+
"agent_token_rate_limit": { "per_minute": 60, "burst": 10 }
363+
}
364+
```
365+
366+
| Field | Type | Default | Description |
367+
|-------|------|---------|-------------|
368+
| `agent_token_rate_limit.per_minute` | integer | unset (off) | Calls a minute each agent token may make. `0` or unset = no limit. Maximum `100000`. |
369+
| `agent_token_rate_limit.burst` | integer | `per_minute` | Calls a token may make at once after a quiet spell. Needs `per_minute` above `0`. |
370+
371+
The limit is a token bucket kept per token: it starts full, spends one call per
372+
upstream tool call and refills at `per_minute` calls a minute. It is checked at
373+
the same admission point as the concurrency limits, so it covers the `call_tool_*`
374+
variants, direct-routing mode, `code_execution` and the REST tool-call endpoint.
375+
It applies to agent tokens only: the admin API key and unauthenticated local
376+
callers are never limited. A token created with `--rate-limit` uses its own budget
377+
instead of this default (`--rate-limit 0` exempts it); see
378+
[Call Rate Limit](features/agent-tokens.md#call-rate-limit).
379+
380+
A refused call never reaches the upstream and takes no concurrency slot. MCP
381+
callers get an error tool result, REST callers get `429` with `Retry-After`, and
382+
the activity log records a `tool_call` with status `rejected`, reason
383+
`rate_limited` and the token's name. Metrics: `mcpproxy_tool_calls_rejected_total`
384+
with `reason="rate_limited"`, `scope="token"` (see the table above).
385+
386+
Buckets are held in memory, so a restart refills them. The block is hot-reloadable;
387+
a changed budget keeps the calls a token has already spent. Negative values, a
388+
value above the maximum, and a `burst` without a `per_minute` are rejected, and
389+
the error names the field.
390+
353391
### Debug & Development
354392

355393
```json

‎docs/configuration/config-file.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,30 @@ Full reference — validation rules, metrics, and which origins are limited —
268268
lives in [`docs/configuration.md`](https://github.com/smart-mcp-proxy/mcpproxy-go/blob/main/docs/configuration.md#concurrency-limits--request-queueing)
269269
in the repository.
270270

271+
### Agent Token Call Rate Limit
272+
273+
Caps how many upstream tool calls one agent token may make per minute. **Off by
274+
default.**
275+
276+
```json
277+
{
278+
"agent_token_rate_limit": { "per_minute": 60, "burst": 10 }
279+
}
280+
```
281+
282+
| Option | Type | Default | Description |
283+
|--------|------|---------|-------------|
284+
| `agent_token_rate_limit.per_minute` | integer | unset (off) | Calls a minute each agent token may make. `0` or unset = no limit; maximum `100000` |
285+
| `agent_token_rate_limit.burst` | integer | `per_minute` | Calls a token may make at once after a quiet spell; needs `per_minute` above `0` |
286+
287+
Each token has its own bucket. Admin API key and unauthenticated callers are not
288+
limited. A token created with `mcpproxy token create --rate-limit N` uses its own
289+
budget instead (`--rate-limit 0` exempts it). A refused call gets a retry-friendly
290+
error (HTTP 429 with `Retry-After` over REST) and a `rejected` activity record
291+
with the reason `rate_limited`. Hot-reloadable.
292+
293+
Full reference: [Call Rate Limit](/features/agent-tokens#call-rate-limit).
294+
271295
### Server Edition (`server_edition`) {#server-edition}
272296

273297
The `server_edition` block configures multi-user SSO in the **Server edition**

0 commit comments

Comments
 (0)