Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
1b14e31
spec(093): request queueing + per-upstream concurrency limits
Dumbris Aug 7, 2026
2c0faa9
spec(093): codex round 1 — lock-free admission, tri-state zeros, atom…
Dumbris Aug 7, 2026
907b60b
spec(093): codex round 2 — shared occupancy across generations, admit…
Dumbris Aug 7, 2026
485d620
spec(093): codex round 3 — align queued-waiter edge case with FR-021 …
Dumbris Aug 7, 2026
b126c49
feat(limiter): bounded-concurrency admission limiter with shared occu…
Dumbris Aug 8, 2026
e12ad09
feat(config): three-scope concurrency limit configuration surface
Dumbris Aug 8, 2026
556b020
feat(upstream): enforce concurrency limits at the managed-client chok…
Dumbris Aug 8, 2026
b685c11
feat(093): shed semantics — isError result, REST 429, "rejected" acti…
Dumbris Aug 8, 2026
cb36de3
test(093): end-to-end queue/shed coverage against a live slow upstream
Dumbris Aug 8, 2026
af51976
feat(api): expose per-server concurrency limits over the REST API
Dumbris Aug 8, 2026
a88660a
fix(activity): accept the "rejected" status across every consumer sur…
Dumbris Aug 8, 2026
9d61394
fix(limiter): one generation, retirement tombstones, always-on occupancy
Dumbris Aug 8, 2026
cb51b1d
fix(replay): honour the caller's context and answer a shed with 429
Dumbris Aug 8, 2026
c1968f0
fix(telemetry): count and record sheds off the lossy event bus
Dumbris Aug 8, 2026
cc736be
fix(activity): one rejected row per shed in listings and summaries
Dumbris Aug 8, 2026
3b60759
fix(activity): attribute a shed to the origin that actually made the …
Dumbris Aug 8, 2026
b74bc37
docs(api): regenerate OpenAPI for the replay 429 response
Dumbris Aug 8, 2026
c714467
fix(limiter): limits live only in the published generation
Dumbris Aug 8, 2026
7fbed5c
fix(activity): put the synchronous shed writer behind the shutdown ba…
Dumbris Aug 8, 2026
c21b3ba
docs(site): put concurrency limits on the docs site and fix the broke…
Dumbris Aug 8, 2026
d5bd80f
docs(site): drop duplicated concurrency sections from the collision
Dumbris Aug 8, 2026
30ce948
fix(limiter): keep FIFO across a cap raise and resolve budgets per scope
Dumbris Aug 8, 2026
86cbf02
Merge main (spec 092 auto-updater) into 093-concurrency-limits; regen…
Dumbris Aug 8, 2026
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
29 changes: 29 additions & 0 deletions cmd/generate-types/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,28 @@ export interface HealthStatus {
action?: HealthAction;
}

`)

// Activity status vocabulary - generated from internal/storage/activity_models.go
// (ValidActivityStatuses). Closed set: every consumer switches on it, so a new
// status has to be added in Go and regenerated here.
sb.WriteString(`// Activity status vocabulary - generated from internal/storage/activity_models.go
export const ActivityStatusSuccess = 'success' as const;
export const ActivityStatusError = 'error' as const;
export const ActivityStatusBlocked = 'blocked' as const;
/** Spec 093: shed by a concurrency limit before reaching the upstream. */
export const ActivityStatusRejected = 'rejected' as const;
export type ActivityStatusValue =
| typeof ActivityStatusSuccess
| typeof ActivityStatusError
| typeof ActivityStatusBlocked
| typeof ActivityStatusRejected;

/** Machine-readable cause of a spec-093 rejection (activity metadata rejection_reason). */
export type RejectionReason = 'queue_full' | 'queue_timeout';
/** Limiter tier that shed the call (activity metadata rejection_scope). */
export type RejectionScope = 'server' | 'global';

`)

// Server types
Expand Down Expand Up @@ -128,6 +150,13 @@ export interface HealthStatus {
health?: HealthStatus; // Unified health status calculated by the backend
trust_mode?: string; // Per-server approval trust mode (spec 086): 'auto' | 'scan' | 'manual'; raw configured value, absent when unset (effective default: manual)
security_scan?: SecurityScanSummary; // Latest scan summary (spec 086); ABSENT when no scan has ever run
// Spec 093 (#955) per-server concurrency overrides. Tri-state: absent =
// inherit server_concurrency_defaults, 0 = disabled for this server,
// positive = override. Effective concurrency is additionally bounded by the
// proxy-wide (global) limiter.
max_concurrent_requests?: number;
queue_size?: number;
queue_timeout?: string; // Go duration string, e.g. '30s'
}

export interface SecurityScanSummary {
Expand Down
16 changes: 12 additions & 4 deletions cmd/mcpproxy/activity_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,9 @@ func (f *ActivityFilter) Validate() error {

// Validate status
if f.Status != "" {
validStatuses := []string{"success", "error", "blocked"}
// Spec 093 added "rejected" (shed by a concurrency limit) to the closed
// activity status vocabulary; the CLI filter must accept it.
validStatuses := []string{"success", "error", "blocked", "rejected"}
valid := false
for _, s := range validStatuses {
if f.Status == s {
Expand Down Expand Up @@ -735,7 +737,7 @@ func init() {
activityListCmd.Flags().StringVarP(&activityType, "type", "t", "", "Filter by type (comma-separated for multiple): tool_call, system_start, system_stop, internal_tool_call, config_change, policy_decision, quarantine_change, server_change")
activityListCmd.Flags().StringVarP(&activityServer, "server", "s", "", "Filter by server name")
activityListCmd.Flags().StringVar(&activityTool, "tool", "", "Filter by tool name")
activityListCmd.Flags().StringVar(&activityStatus, "status", "", "Filter by status: success, error, blocked")
activityListCmd.Flags().StringVar(&activityStatus, "status", "", "Filter by status: success, error, blocked, rejected")
activityListCmd.Flags().StringVar(&activitySessionID, "session", "", "Filter by session — a work session id (ws-...) or a raw MCP transport session id")
activityListCmd.Flags().StringVar(&activityStartTime, "start-time", "", "Filter records after this time (RFC3339)")
activityListCmd.Flags().StringVar(&activityEndTime, "end-time", "", "Filter records before this time (RFC3339)")
Expand Down Expand Up @@ -772,7 +774,7 @@ func init() {
activityExportCmd.Flags().StringVarP(&activityType, "type", "t", "", "Filter by type (comma-separated): tool_call, system_start, system_stop, internal_tool_call, config_change, policy_decision, quarantine_change, server_change")
activityExportCmd.Flags().StringVarP(&activityServer, "server", "s", "", "Filter by server name")
activityExportCmd.Flags().StringVar(&activityTool, "tool", "", "Filter by tool name")
activityExportCmd.Flags().StringVar(&activityStatus, "status", "", "Filter by status")
activityExportCmd.Flags().StringVar(&activityStatus, "status", "", "Filter by status: success, error, blocked, rejected")
activityExportCmd.Flags().StringVar(&activitySessionID, "session", "", "Filter by session — a work session id (ws-...) or a raw MCP transport session id")
activityExportCmd.Flags().StringVar(&activityStartTime, "start-time", "", "Filter after this time (RFC3339)")
activityExportCmd.Flags().StringVar(&activityEndTime, "end-time", "", "Filter before this time (RFC3339)")
Expand Down Expand Up @@ -1208,8 +1210,11 @@ func formatToolCallEvent(event map[string]interface{}, timestamp string) string
if errMsg != "" {
line += " " + errMsg
}
if status == "blocked" {
switch status {
case "blocked":
line += " BLOCKED"
case "rejected":
line += " REJECTED (concurrency limit)"
}
return line
}
Expand Down Expand Up @@ -1308,6 +1313,9 @@ func formatStatusIcon(status string) string {
return "\u2717" // X
case "blocked":
return "\u2298" // circle with slash
case "rejected":
// Spec 093: shed by a concurrency limit — backpressure, not a failure.
return "\u23f8" // pause
default:
return "?"
}
Expand Down
3 changes: 2 additions & 1 deletion docs/cli/activity-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ mcpproxy activity list [flags]
| `--type` | `-t` | | Filter by type (comma-separated for multiple): `tool_call`, `system_start`, `system_stop`, `internal_tool_call`, `config_change`, `policy_decision`, `quarantine_change`, `server_change`, `credential_broker` |
| `--server` | `-s` | | Filter by server name |
| `--tool` | | | Filter by tool name |
| `--status` | | | Filter by status: `success`, `error`, `blocked` |
| `--status` | | | Filter by status: `success`, `error`, `blocked`, `rejected` (shed by a concurrency limit, see [Concurrency Limits](../configuration/config-file.md#concurrency-limits--request-queueing)) |
| `--intent-type` | | | Filter by intent operation type: `read`, `write`, `destructive` |
| `--request-id` | | | Filter by HTTP request ID for log correlation |
| `--no-icons` | | | Disable emoji icons in output (use text instead) |
Expand Down Expand Up @@ -377,6 +377,7 @@ database:query 15 calls
"success_count": 142,
"error_count": 5,
"blocked_count": 3,
"rejected_count": 0,
"success_rate": 0.947,
"top_servers": [
{"name": "github", "count": 75},
Expand Down
137 changes: 137 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,137 @@ the proxy.
> the Web UI / macOS app is planned; for now set per-server overrides via the
> Raw JSON editor or the REST API.

### Concurrency Limits & Request Queueing

Multi-user or multi-agent deployments can overwhelm a fragile upstream (a
database-backed stdio server, a rate-limited API) with simultaneous tool calls.
MCPProxy can cap how many upstream tool calls run at once and park the excess in
a bounded FIFO queue, shedding predictably when that queue is full
(GitHub [#955](https://github.com/smart-mcp-proxy/mcpproxy-go/issues/955)).

**Everything is off by default** — with no keys set, behavior is exactly as
before: no limiting, no queueing, no new errors.

There are **three separately named scopes**, each carrying the same three
settings:

| Scope | Where | What it caps |
|-------|-------|--------------|
| Global aggregate limiter | top-level `max_concurrent_requests` / `queue_size` / `queue_timeout` | All upstream tool calls across the whole proxy |
| Per-server default set | `server_concurrency_defaults` object | Blanket per-server values, inherited by every server that does not override them |
| Per-server override | the same three keys on an `mcpServers[]` entry | That one server |

```json
{
"max_concurrent_requests": 50,
"queue_size": 100,
"queue_timeout": "30s",

"server_concurrency_defaults": {
"max_concurrent_requests": 5,
"queue_size": 10,
"queue_timeout": "30s"
},

"mcpServers": [
{ "name": "fragile-db", "command": "db-mcp", "max_concurrent_requests": 1, "queue_size": 2 },
{ "name": "fast-api", "url": "https://api.example.com/mcp", "max_concurrent_requests": 0 }
]
}
```

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `max_concurrent_requests` | integer | unset (off) | Maximum upstream tool calls running at once in this scope. `0` (or unset) = no limiter for this scope. |
| `queue_size` | integer | `0` | How many calls may wait for a slot. `0` = no pending capacity: a call arriving at the cap is shed immediately. |
| `queue_timeout` | duration | `"30s"` when a limiter is active | How long a call may wait in the queue before being shed. |

**Tri-state per-server semantics.** Each per-server key is independently
tri-state:

- **absent** — inherit the value from `server_concurrency_defaults`;
- **`0`** — disable that setting for this server. `max_concurrent_requests: 0`
opts the server out of per-server limiting entirely (even when the default set
configures one); `queue_size: 0` keeps the cap but removes the queue, so
excess calls are shed instantly;
- **positive** — override the default for this server.

The global aggregate limiter is **never** an inheritance source for a server.
It applies *on top*: a server's effective concurrency is
**min(resolved per-server limit, global limit)**. Waiting for a per-server slot
does not consume global capacity — the per-server slot is taken first.

**One deadline, not two.** `queue_timeout` is a total wait budget, not a
per-tier one: a call waiting for a per-server slot and then a global slot shares
a single absolute deadline (the smallest configured timeout among the active
scopes). Queue waiting never consumes the call's execution timeout — the
execution budget starts after admission.

**Recommended starting point for stdio upstreams: `5`.** stdio does not mean
serial: the MCP stdio transport multiplexes by JSON-RPC id and common SDK
servers process calls through a small worker pool, so `5` mirrors typical
upstream capacity. Drop to `1` only for a server you know is single-threaded or
backed by a fragile store.

**Shedding.** A shed call is reported as a readable, retry-friendly error rather
than a dropped connection: MCP tool calls get an error tool result, the REST
tool-call endpoint returns `429` with `Retry-After`, and the activity log
records the call with a `rejected` status carrying the reason (`queue_full` or
`queue_timeout`) and scope (`server` or `global`).

**Hot reload.** All limits are hot-reloadable — edit the config file and the new
values govern subsequent admissions without a restart. Running calls are never
interrupted, but they keep counting against the new caps: after lowering a cap,
nothing new is admitted until occupancy drains below it. Raising a cap admits
waiting calls immediately; queued calls keep their original deadline.

**Validation.** Negative values are rejected, as is a positive `queue_size` in a
scope whose `max_concurrent_requests` resolves to disabled (a queue in front of
no limiter can never admit anything). Errors name the offending scope and field.
The exception is the documented opt-out above: an explicit per-server
`max_concurrent_requests: 0` is valid even when the default set defines a queue.

Only the **global aggregate** scope has environment overrides
(`MCPPROXY_MAX_CONCURRENT_REQUESTS`, `MCPPROXY_QUEUE_SIZE`,
`MCPPROXY_QUEUE_TIMEOUT`); the default set and per-server overrides are
file/API-configured.

**Per-server limits over REST.** `POST /api/v1/servers` and
`PATCH /api/v1/servers/{name}` accept `max_concurrent_requests`, `queue_size`
and `queue_timeout` alongside the other per-server fields, and
`GET /api/v1/servers` echoes them back. All three keep tri-state semantics on
PATCH: omitting a key leaves the stored value alone, and an explicit `0` is the
documented opt-out — it is applied, not treated as "unset".

```bash
curl -X PATCH http://127.0.0.1:8080/api/v1/servers/fragile-db \
-H "X-API-Key: $MCPPROXY_API_KEY" -H 'Content-Type: application/json' \
-d '{"max_concurrent_requests": 1, "queue_size": 2, "queue_timeout": "10s"}'
```

**What is limited, and what is not.** Limits apply to *upstream tool calls* from
every in-process origin — the `call_tool_*` variants, direct-routing mode, the
REST `POST /api/v1/tools/call` endpoint, sandboxed `code_execution` scripts and
activity replay. Local tool search, coalesced tool listings and health probes
are never throttled: they are lightweight and must not be able to queue behind a
saturated upstream. The separate-process CLI debug client is out of scope.

**Observability.** Saturation is visible on the Prometheus surface:

| 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_concurrency_active` | gauge | `scope`, `server` | Calls currently holding a slot. |
| `mcpproxy_concurrency_queue_depth` | gauge | `scope`, `server` | Calls currently waiting for a slot. |

Gauges are sampled every 10s; the counter is exact — it is incremented at the
rejection itself, not derived from the internal event stream, so a burst of
sheds cannot lose increments. The same sheds also appear in the activity log
(`status: rejected`) — written on the same synchronous path, and exactly one row
per shed — including the ones from `code_execution` and replay: the rejection is
recorded at the limiter, below the MCP dispatch layer, so no origin can bypass
it and none can double-report it.

### Debug & Development

```json
Expand Down Expand Up @@ -237,6 +368,9 @@ the proxy.
| `health_check_interval` | duration | No | Per-server override for the global [`health_check_interval`](#discovery--health-checks). `"0s"` disables the liveness probe for this server only. Range: `5s`–`1h`. Omit to inherit the global value. |
| `tool_discovery_interval` | duration | No | Per-server override for the global [`tool_discovery_interval`](#discovery--health-checks). Overrides the global/default cadence for this server only; `"0s"` disables the periodic tool-discovery sweep for this server (connect-time and reactive `list_changed` discovery still run). Range: `30s`–`24h`. Omit to inherit the global value. |
| `init_timeout` | duration | No | Per-server override for the global [`init_timeout`](#search--tool-limits) — the MCP `initialize` handshake deadline. Raise it for an upstream that warms up (caches/indexes data) before responding to `initialize` (e.g. `"120s"`, `"3m"`); without it such a server is killed mid-startup and, with `docker run --rm`, retries forever. Range: `1s`–`30m`. Omit to inherit the global value (30s default). Settable via the `upstream_servers` tool and `mcpproxy upstream patch --init-timeout`. |
| `max_concurrent_requests` | integer | No | Per-server cap on concurrently running upstream tool calls (see [Concurrency Limits & Request Queueing](#concurrency-limits--request-queueing)). Omit to inherit `server_concurrency_defaults`; `0` opts this server out of per-server limiting; positive = that cap. The global limiter still applies on top. |
| `queue_size` | integer | No | How many calls may wait for this server's slot. Omit to inherit the default set; `0` = no pending capacity (shed immediately at the cap). |
| `queue_timeout` | duration | No | How long a call may wait for this server's slot (e.g. `"10s"`). Omit to inherit the default set (30s when a limiter is active). |
| `oauth` | object | No | OAuth configuration (see [OAuth Configuration](#oauth-configuration)) |
| `isolation` | object | No | Per-server Docker isolation settings (see [Docker Isolation](#docker-isolation)) |
| `enabled` | boolean | No | Enable/disable server (default: `true`) |
Expand Down Expand Up @@ -1307,6 +1441,9 @@ Many configuration options can be overridden via environment variables:
| `MCPPROXY_CERTS_DIR` | `tls.certs_dir` | Custom certificates directory |
| `MCPPROXY_DATA` | `data_dir` | Override data directory |
| `MCPPROXY_TOOL_RESPONSE_MODE` | `tool_response_mode` | `retrieve_tools` serialization: `full` (default) or `compact` |
| `MCPPROXY_MAX_CONCURRENT_REQUESTS` | `max_concurrent_requests` | Global aggregate cap on concurrent upstream tool calls (`0` disables it). See [Concurrency Limits](#concurrency-limits--request-queueing) |
| `MCPPROXY_QUEUE_SIZE` | `queue_size` | Global aggregate wait-queue length (`0` = shed at the cap) |
| `MCPPROXY_QUEUE_TIMEOUT` | `queue_timeout` | Global aggregate queue wait budget, e.g. `30s` |
| `MCPPROXY_DISABLE_OAUTH` | - | Disable OAuth for testing |
| `HEADLESS` | - | Run in headless mode |

Expand Down
61 changes: 61 additions & 0 deletions docs/configuration/config-file.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,67 @@ They only widen in one direction — they cannot re-enable checking that the
config disabled. See [Version Updates](/features/version-updates) for where
updates are surfaced.

### Concurrency Limits & Request Queueing

Caps how many upstream tool calls may run at once, so a burst cannot overwhelm a
fragile upstream. **Off by default** — with no keys set there is no limiting, no
queueing and no new errors.

Three separately named scopes carry the same three settings:

| Scope | Where | What it caps |
|-------|-------|--------------|
| Global aggregate | top-level `max_concurrent_requests` / `queue_size` / `queue_timeout` | All upstream tool calls across the whole proxy |
| Per-server defaults | `server_concurrency_defaults` object | Blanket per-server values, inherited by servers that do not override them |
| Per-server override | the same three keys on an `mcpServers[]` entry | That one server |

```json
{
"max_concurrent_requests": 50,
"queue_size": 100,
"queue_timeout": "30s",

"server_concurrency_defaults": {
"max_concurrent_requests": 5,
"queue_size": 10
},

"mcpServers": [
{ "name": "fragile-db", "command": "db-mcp", "max_concurrent_requests": 1, "queue_size": 2 },
{ "name": "fast-api", "url": "https://api.example.com/mcp", "max_concurrent_requests": 0 }
]
}
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `max_concurrent_requests` | integer | unset (off) | Upstream tool calls allowed to run at once in this scope. `0` or unset = no limiter for this scope |
| `queue_size` | integer | `0` | How many calls may wait for a slot. `0` = shed immediately at the cap |
| `queue_timeout` | duration | `"30s"` when a limiter is active | How long a call may wait before being shed |

**Tri-state per-server semantics.** Each per-server key is independent: **absent**
inherits from `server_concurrency_defaults`, **`0`** disables that setting for
this server (`max_concurrent_requests: 0` opts the server out of per-server
limiting entirely), and a **positive** value overrides the default.

The global limiter is never an inheritance source — it applies on top, so a
server's effective concurrency is **min(per-server limit, global limit)**.
`queue_timeout` is one total wait budget across both tiers, not one per tier,
and queue waiting never eats into the call's execution timeout.

**Shedding.** A shed call gets a readable, retry-friendly error: an error tool
result for MCP calls, HTTP 429 with `Retry-After` for the REST tool-call
endpoint, and an activity record with the `rejected` status carrying the reason
(`queue_full` or `queue_timeout`) and scope (`server` or `global`). All limits
are hot-reloadable.

For stdio upstreams, start at `5` rather than `1`: the transport multiplexes and
most SDK servers use a small worker pool.

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.

### MCP Servers

See [Upstream Servers](/configuration/upstream-servers) for detailed server configuration.
Expand Down
Loading
Loading