Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ Releases follow [Semantic Versioning](https://semver.org/).

### Features

- **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.
- **profiles:** `mcpproxy access suggest` turns the calls your profiles blocked into reviewed profile
changes (#1700). It reads the profile refusals in the audit log (`--since`, default 24h), groups them
by profile, tool and block reason, re-checks each group with the access explainer so tools that are
Expand Down
78 changes: 76 additions & 2 deletions cmd/mcpproxy/token_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ var (
tokenExpires string
tokenProfilePin string
tokenProfile string
// tokenRateLimit / tokenRateBurst are the token's own call budget (issue
// #1699). Only sent when the flag was given, so an omitted --rate-limit
// leaves the token on the agent_token_rate_limit default.
tokenRateLimit int
tokenRateBurst int

// token list filters (Spec 108-g, FR-031)
tokenListProfile string
Expand Down Expand Up @@ -82,7 +87,14 @@ without --profile at least one of them is required.
Examples:
mcpproxy token create --name ci --profile work-readonly
mcpproxy token create --name deploy-bot --servers github,gitlab --permissions read,write
mcpproxy token create --name ci-agent --servers "*" --permissions read --expires 7d`,
mcpproxy token create --name ci-agent --servers "*" --permissions read --expires 7d
mcpproxy token create --name loop-guard --profile work-readonly --rate-limit 60 --rate-burst 10

--rate-limit caps how many upstream tool calls the token may make: that many a
minute on average, --rate-burst at once (default: the same number). A call over
the budget is refused with a rate_limited error and a retry hint, and is
recorded as a rejected call. 0 exempts the token from the agent_token_rate_limit
default in the config; leave the flag out to follow that default.`,
RunE: runTokenCreate,
}

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

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

var limitSet, burstSet bool
if cmd != nil {
limitSet, burstSet = cmd.Flags().Changed("rate-limit"), cmd.Flags().Changed("rate-burst")
}
rateLimit, err := tokenRateLimitFromFlags(limitSet, burstSet)
if err != nil {
return err
}

client, _, err := newTokenCLIClient()
if err != nil {
return err
Expand All @@ -208,6 +231,9 @@ func runTokenCreate(_ *cobra.Command, _ []string) error {
if tokenPermissions != "" {
body["permissions"] = splitAndTrim(tokenPermissions)
}
if rateLimit != nil {
body["rate_limit"] = rateLimit
}

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

return nil
}

// tokenRateLimitFromFlags builds the rate_limit request member from the
// --rate-limit / --rate-burst flags, or nil when neither was given. It checks
// the numbers locally so a typo exits 1 before anything is sent.
func tokenRateLimitFromFlags(limitSet, burstSet bool) (map[string]int, error) {
if !limitSet && !burstSet {
return nil, nil
}
if tokenRateLimit < 0 {
return nil, newFlagValidationError("--rate-limit cannot be negative: use 0 for no limit")
}
if tokenRateBurst < 0 {
return nil, newFlagValidationError("--rate-burst cannot be negative")
}
if burstSet && tokenRateLimit == 0 {
return nil, newFlagValidationError("--rate-burst needs --rate-limit above 0")
}
out := map[string]int{"per_minute": tokenRateLimit}
if tokenRateBurst > 0 {
out["burst"] = tokenRateBurst
}
return out, nil
}

// formatTokenRateLimit renders a token's rate_limit response member, or "" when
// the token has none of its own.
func formatTokenRateLimit(v interface{}) string {
m, ok := v.(map[string]interface{})
if !ok {
return ""
}
perMinute, _ := m["per_minute"].(float64)
burst, _ := m["burst"].(float64)
switch {
case perMinute <= 0:
return "none (exempt from the config default)"
case burst > 0 && burst != perMinute:
return fmt.Sprintf("%d calls/minute, bursts of up to %d", int(perMinute), int(burst))
default:
return fmt.Sprintf("%d calls/minute", int(perMinute))
}
}

// printTokenStructured prints a token response as json or yaml.
func printTokenStructured(result map[string]interface{}) error {
formatter, err := GetOutputFormatter()
Expand Down Expand Up @@ -428,6 +499,9 @@ func runTokenShow(_ *cobra.Command, args []string) error {
if revoked, ok := result["revoked"].(bool); ok {
fmt.Printf("Revoked: %v\n", revoked)
}
if line := formatTokenRateLimit(result["rate_limit"]); line != "" {
fmt.Printf("Rate limit: %s\n", line)
}
printField("Created: ", result, "created_at")
printField("Expires: ", result, "expires_at")
if lastUsed := getMapString(result, "last_used_at"); lastUsed != "" {
Expand Down
53 changes: 53 additions & 0 deletions cmd/mcpproxy/token_cmd_profile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,56 @@ func TestTokenShow_LegacyScopeHintAndClientKind(t *testing.T) {
require.Contains(t, out, "Manage with: mcpproxy client show cursor")
require.NotContains(t, out, "Legacy scope")
}

// Issue #1699: --rate-limit / --rate-burst set the token's own call budget.

func TestTokenCreate_RateLimitFlags(t *testing.T) {
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}}`
rec := newTokenRecorder(t, map[string]cannedResponse{"POST /api/v1/tokens": createdResp(withLimit)})
out, _, err := runCLI(t, tokenCommandForTest, "table", "create", "--name", "ci", "--profile", "work-ro2", "--rate-limit", "60", "--rate-burst", "10")
require.NoError(t, err)
body := rec.only(t).jsonBody(t)
require.Equal(t, map[string]any{"per_minute": float64(60), "burst": float64(10)}, body["rate_limit"])
require.Contains(t, out, "Rate limit: 60 calls/minute, bursts of up to 10")
}

func TestTokenCreate_RateLimitOmittedByDefault(t *testing.T) {
rec := newTokenRecorder(t, map[string]cannedResponse{"POST /api/v1/tokens": createdResp(createdToken)})
out, _, err := runCLI(t, tokenCommandForTest, "table", "create", "--name", "ci", "--profile", "work-ro2")
require.NoError(t, err)
require.NotContains(t, rec.only(t).jsonBody(t), "rate_limit", "no flag, no member: the config default applies")
require.NotContains(t, out, "Rate limit:")
}

func TestTokenCreate_RateLimitZeroIsSentAsAnExemption(t *testing.T) {
rec := newTokenRecorder(t, map[string]cannedResponse{"POST /api/v1/tokens": createdResp(createdToken)})
_, _, err := runCLI(t, tokenCommandForTest, "table", "create", "--name", "ci", "--profile", "work-ro2", "--rate-limit", "0")
require.NoError(t, err)
require.Equal(t, map[string]any{"per_minute": float64(0)}, rec.only(t).jsonBody(t)["rate_limit"])
}

func TestTokenCreate_RateLimitRefusedLocally(t *testing.T) {
for name, args := range map[string][]string{
"negative rate": {"--rate-limit", "-5"},
"negative burst": {"--rate-limit", "5", "--rate-burst", "-1"},
"burst without rate": {"--rate-burst", "5"},
"burst with zero rate": {"--rate-limit", "0", "--rate-burst", "5"},
} {
t.Run(name, func(t *testing.T) {
rec := newTokenRecorder(t, map[string]cannedResponse{})
_, _, err := runCLI(t, tokenCommandForTest, "table", append([]string{"create", "--name", "ci", "--profile", "p"}, args...)...)
require.Error(t, err)
require.Equal(t, ExitCodeGeneralError, classifyError(err))
require.Empty(t, rec.all(), "a bad number exits before anything is sent")
})
}
}

func TestFormatTokenRateLimit(t *testing.T) {
require.Equal(t, "", formatTokenRateLimit(nil))
require.Equal(t, "", formatTokenRateLimit("junk"))
require.Equal(t, "none (exempt from the config default)", formatTokenRateLimit(map[string]interface{}{"per_minute": float64(0)}))
require.Equal(t, "30 calls/minute", formatTokenRateLimit(map[string]interface{}{"per_minute": float64(30)}))
require.Equal(t, "30 calls/minute", formatTokenRateLimit(map[string]interface{}{"per_minute": float64(30), "burst": float64(30)}))
require.Equal(t, "30 calls/minute, bursts of up to 5", formatTokenRateLimit(map[string]interface{}{"per_minute": float64(30), "burst": float64(5)}))
}
40 changes: 39 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ saturated upstream. The separate-process CLI debug client is out of scope.

| Metric | Type | Labels | Meaning |
|--------|------|--------|---------|
| `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. |
| `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. |
| `mcpproxy_concurrency_active` | gauge | `scope`, `server` | Calls currently holding a slot. |
| `mcpproxy_concurrency_queue_depth` | gauge | `scope`, `server` | Calls currently waiting for a slot. |

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

### Agent Token Call Rate Limit

Caps how many upstream tool calls one [agent token](features/agent-tokens.md)
may make per minute, so a runaway agent loop cannot burn through an upstream's
quota (GitHub [#1699](https://github.com/smart-mcp-proxy/mcpproxy-go/issues/1699)).
**Off by default.**

```json
{
"agent_token_rate_limit": { "per_minute": 60, "burst": 10 }
}
```

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `agent_token_rate_limit.per_minute` | integer | unset (off) | Calls a minute each agent token may make. `0` or unset = no limit. Maximum `100000`. |
| `agent_token_rate_limit.burst` | integer | `per_minute` | Calls a token may make at once after a quiet spell. Needs `per_minute` above `0`. |

The limit is a token bucket kept per token: it starts full, spends one call per
upstream tool call and refills at `per_minute` calls a minute. It is checked at
the same admission point as the concurrency limits, so it covers the `call_tool_*`
variants, direct-routing mode, `code_execution` and the REST tool-call endpoint.
It applies to agent tokens only: the admin API key and unauthenticated local
callers are never limited. A token created with `--rate-limit` uses its own budget
instead of this default (`--rate-limit 0` exempts it); see
[Call Rate Limit](features/agent-tokens.md#call-rate-limit).

A refused call never reaches the upstream and takes no concurrency slot. MCP
callers get an error tool result, REST callers get `429` with `Retry-After`, and
the activity log records a `tool_call` with status `rejected`, reason
`rate_limited` and the token's name. Metrics: `mcpproxy_tool_calls_rejected_total`
with `reason="rate_limited"`, `scope="token"` (see the table above).

Buckets are held in memory, so a restart refills them. The block is hot-reloadable;
a changed budget keeps the calls a token has already spent. Negative values, a
value above the maximum, and a `burst` without a `per_minute` are rejected, and
the error names the field.

### Debug & Development

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

### Agent Token Call Rate Limit

Caps how many upstream tool calls one agent token may make per minute. **Off by
default.**

```json
{
"agent_token_rate_limit": { "per_minute": 60, "burst": 10 }
}
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `agent_token_rate_limit.per_minute` | integer | unset (off) | Calls a minute each agent token may make. `0` or unset = no limit; maximum `100000` |
| `agent_token_rate_limit.burst` | integer | `per_minute` | Calls a token may make at once after a quiet spell; needs `per_minute` above `0` |

Each token has its own bucket. Admin API key and unauthenticated callers are not
limited. A token created with `mcpproxy token create --rate-limit N` uses its own
budget instead (`--rate-limit 0` exempts it). A refused call gets a retry-friendly
error (HTTP 429 with `Retry-After` over REST) and a `rejected` activity record
with the reason `rate_limited`. Hot-reloadable.

Full reference: [Call Rate Limit](/features/agent-tokens#call-rate-limit).

### Server Edition (`server_edition`) {#server-edition}

The `server_edition` block configures multi-user SSO in the **Server edition**
Expand Down
Loading
Loading