diff --git a/cmd/generate-types/main.go b/cmd/generate-types/main.go
index 743516d67..474536191 100644
--- a/cmd/generate-types/main.go
+++ b/cmd/generate-types/main.go
@@ -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
@@ -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 {
diff --git a/cmd/mcpproxy/activity_cmd.go b/cmd/mcpproxy/activity_cmd.go
index 18488063f..ed30dacfe 100644
--- a/cmd/mcpproxy/activity_cmd.go
+++ b/cmd/mcpproxy/activity_cmd.go
@@ -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 {
@@ -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)")
@@ -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)")
@@ -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
}
@@ -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 "?"
}
diff --git a/docs/cli/activity-commands.md b/docs/cli/activity-commands.md
index 1d7616e17..e8f17b8d6 100644
--- a/docs/cli/activity-commands.md
+++ b/docs/cli/activity-commands.md
@@ -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) |
@@ -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},
diff --git a/docs/configuration.md b/docs/configuration.md
index 204f0dafb..778e9feba 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -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
@@ -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`) |
@@ -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 |
diff --git a/docs/configuration/config-file.md b/docs/configuration/config-file.md
index 663fb90c2..ebbf3bcc7 100644
--- a/docs/configuration/config-file.md
+++ b/docs/configuration/config-file.md
@@ -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.
diff --git a/docs/configuration/environment-variables.md b/docs/configuration/environment-variables.md
index 6eece1208..90fb14e72 100644
--- a/docs/configuration/environment-variables.md
+++ b/docs/configuration/environment-variables.md
@@ -63,6 +63,18 @@ These variables control browser behavior for OAuth flows:
| `CI` | CI environment detection (disables browser) | - |
| `BROWSER` | Custom browser executable for OAuth | System default |
+### Concurrency Limits
+
+These override the **global aggregate** limiter only — the per-server default
+set and per-server overrides are file/API-configured. See
+[Concurrency Limits & Request Queueing](./config-file.md#concurrency-limits--request-queueing).
+
+| Variable | Description | Default |
+|----------|-------------|---------|
+| `MCPPROXY_MAX_CONCURRENT_REQUESTS` | Proxy-wide cap on upstream tool calls running at once. `0` disables the global limiter | `0` (off) |
+| `MCPPROXY_QUEUE_SIZE` | How many calls may wait for a global slot. `0` = shed immediately at the cap | `0` |
+| `MCPPROXY_QUEUE_TIMEOUT` | How long a call may wait before being shed, e.g. `30s` | `30s` when the limiter is active |
+
### Core Server Examples
```bash
diff --git a/docs/research/request-concurrency-issue-955-2026-08-07.html b/docs/research/request-concurrency-issue-955-2026-08-07.html
new file mode 100644
index 000000000..51fdc8050
--- /dev/null
+++ b/docs/research/request-concurrency-issue-955-2026-08-07.html
@@ -0,0 +1,295 @@
+
+
+
Request queueing & concurrency limits — decision report (#955)
+
+
+ MCPProxy · Engineering decision report
+ Request queueing & per-upstream concurrency limits
+
+ Issue #955 — request queueing / concurrency limit for multi-user deployments
+ · 2026-08-07 · 21-agent research workflow; choke-point analysis verified against the code
+
+
+
+
Recommendation
+
Option A — a two-tier semaphore limiter inside managed.Client.CallTool , per-server first, then global.
+
It is the only placement that covers every in-daemon upstream call path — the audit proved code_execution and activity replay bypass Manager.CallTool and hit the managed client directly. It needs no new dependency (golang.org/x/sync is already pinned), inherits FIFO fairness and ctx-aware queue_timeout from semaphore.Weighted, sidesteps the Manager.CallTool RLock stall hazard, and gets hot-reload propagation free from the existing SetGlobalConfig fan-out. Config: max_concurrent_requests, queue_size, queue_timeout — global + per-server overrides, 0 = unlimited (pure opt-in, zero behavior change on upgrade) .
+
+
+ 01 The choke point (verified)
+ All four dispatch paths converge on one function — and two of them bypass the Manager entirely, which rules out any limiter placed above the managed client:
+
+call_tool_read|write|destructive mcp.go:2112 ──┐
+legacy call_tool mcp.go:2536 ──┼── Manager.CallTool ──┐
+direct-routing mode mcp_routing.go:216 ──┘ manager.go:1123 │
+REST /api/v1/tools/call → CallToolDirect mcp.go:5356 ──(same variants)──┤
+ ├──▶ managed.Client.CallTool
+code_execution mcp_code_execution.go:453-464 ──── GetClient ─────────┤ client.go:640 — limiter here
+activity replay runtime.go:1218-1228 ──── GetClient ─────────┘
+
+ No limit exists today. The only serialization is sseRequestMu for SSE transports. Stdio upstreams are fully multiplexed by mcp-go v0.57.0 (frame write under stdinMu, responses matched by JSON-RPC id) — so queued goroutines block naturally before the frame write, and a semaphore wrapping CallTool works for stdio too.
+ core/client.go:70,396-405 · mcp-go stdio.go:423-489
+ Queue wait won’t eat call_tool_timeout: that 2-minute context is created deeper, inside core.Client.CallTool — acquiring before delegation keeps queue time separate.
+ core/client.go:430-445
+ Server edition is covered automatically: the multi-user Router only filters visibility; all users share one managed client per server, so per-server limits bound aggregate multi-user load with zero extra code.
+ internal/serveredition/multiuser/router.go
+ Deliberately outside the limit: retrieve_tools (local Bleve, no upstream traffic), ListTools (already leader/follower-coalesced), health-check Ping (5s lightweight probe), and the separate-process CLI debug client.
+ managed/client.go:504-637, 971-985 · cli/client.go:266
+ Hot-reload is free: per-server config flows through atomic GetConfig/SetConfig; global config fans out via Manager.SetGlobalConfig. Caveat: semaphore.Weighted can’t resize, so reload means an atomic limiter swap with release-closures bound to the old instance.
+ manager.go:298-314
+
+
+ 02 Options
+
+ Option Covers code_execution / replay Effort Risk
+
+ A · Two-tier semaphore in managed client Yes — only option that does M Low-medium
+ B · Manager.CallTool + MCP dispatch No — verified bypass holeS Medium
+ C · Inbound HTTP middleware only No per-upstream limits at all S Low impl / high product
+ D · Bounded-queue dispatcher per upstream Yes L High
+
+
+
+
+
+
New internal/upstream/limiter package on x/sync/semaphore. Each limiter = two weighted semaphores: an admission semaphore sized max_concurrent + queue_size acquired with TryAcquire (failure = queue full → instant shed, Envoy max_pending_requests semantics), and a run semaphore sized max_concurrent acquired ctx-aware under queue_timeout. One limiter per server (registry keyed by name) + one global; acquired per-server-first inside managed.Client.CallTool.
+
+
Pros
+ Covers 100% of in-daemon upstream traffic including the code_execution and replay bypass paths — the only placement that does.
+ No new dependency; semaphore.Weighted gives FIFO fairness, cancellable waits, and queue_timeout free.
+ Server edition works automatically (shared managed client per server bounds aggregate multi-user load).
+ Hot-reload free via existing SetGlobalConfig fan-out + per-server config atomics.
+ Avoids the Manager.CallTool RLock hazard (blocking there stalls AddServer/RemoveServer writers).
+ Registry keyed by server name is forward-compatible with Spec 074 per-(user,server) brokered pools.
+ Protects stdio naturally — the transport genuinely multiplexes, so waiters just block before the frame write.
+
+
Cons
+ Touches a hot, subtle file (managed/client.go ) — must not regress the state machine, ListTools coalescing, or reconnect paths.
+ Typed shed errors must propagate to mcp.go/httpapi for correct rendering (isError vs 429) — a small cross-layer contract.
+ Semaphores can’t resize → hot-reload needs the atomic-swap-with-bound-release-closures pattern (fiddly but unit-testable).
+ Doesn’t govern the separate-process CLI debug client — acceptable; document it.
+
+
+
+
+
+
Option B — Limiter in Manager.CallTool + MCP dispatch layer
+ Effort S Risk Medium
+
Global limit in the mcp.go call_tool dispatch; per-server limit in Manager.CallTool. No managed-client changes.
+
+
Pros
+ Smaller upstream-layer diff; mcp.go already has activity/span/metrics seams in place.
+ Global sheds map cleanly to MCP tool errors with no error-type plumbing.
+
+
Cons
+ Verified hole: code_execution and replay call the managed client directly — one script can still stampede a stdio upstream, defeating the issue’s core requirement.
+ Manager.CallTool holds m.mu.RLock for the whole call; a limiter blocking under it stalls AddServer/RemoveServer — fixing that replicates the drop-and-reacquire dance Option A avoids.
+ Two enforcement sites; future call paths must remember to route through them.
+
+
+
+
+
+
+
A chi middleware capping concurrent inbound requests on /mcp and /api/v1, returning 429 when saturated. No per-upstream awareness.
+
+
Pros
+ Trivial; standard reverse-proxy pattern; clean 429 semantics on REST; protects the daemon’s own goroutine budget.
+
+
Cons
+ Cannot express per-upstream limits — one slow stdio server still absorbs unbounded calls while fast servers get throttled: the opposite of what #955 asks.
+ Amplification blind spot: one code_execution request fans out to many upstream calls, invisible to inbound counting.
+ Transport-level sheds can abort agent client loops; internal callers (replay) never traverse the listener.
+
+
+
+
+
+
Option D — Explicit bounded-queue dispatcher (worker pool per upstream)
+ Effort L Risk High
+
Dedicated dispatcher goroutine + FIFO queue struct per upstream, requests as first-class objects with deadlines, identity, priority — mirroring mcp-go’s own stdio worker pool (5 workers / queue 100).
+
+
Pros
+ Richest future capabilities: queue introspection in the Web UI, per-user fair queuing, priorities, cancellation by id.
+
+
Cons
+ Substantially more code and state for behavior two semaphores already deliver (semaphore waiters are FIFO — fairness is equal today).
+ New failure modes: dispatcher leaks, queue-entry lifecycle bugs, shutdown ordering against the managed-client state machine.
+ Per-user fairness — the one thing this buys — is a server-edition concern with no current demand, interacting with unimplemented Spec 074. Speculative now.
+
+
+
+
+ 03 Shed semantics
+
+ Surface On queue full / timeout Why
+
+ MCP tools/call isError:true tool result: “server '<name>' is at its concurrency limit (<n> running, <q> queued). Retry in a few seconds.”The MCP spec’s own canonical isError example is a rate-limit message; agent LLMs read it and back off. A JSON-RPC protocol error can abort some client loops.
+ REST API HTTP 429 + Retry-After nginx-documented API-correct status; Envoy/LiteLLM precedent.
+ Activity log New status rejected (not generic error) Dashboards must separate saturation from upstream failure.
+ Metrics mcpproxy_tool_calls_rejected_total{server, reason="queue_full"|"queue_timeout"} + queue-depth gaugeEnvoy-style overflow counters; via the event-bus metrics bridge.
+
+
+ Stdio does not mean limit=1. mcp-go’s own stdio server runs tools/call through a 5-worker pool (queue 100); the client transport legally pipelines by JSON-RPC id; TS/Python SDKs process concurrently. Docs should recommend max_concurrent_requests: 5 for stdio upstreams, with 1 as the floor for fragile servers.
+
+ 04 Implementation plan
+
+
+ Limiter package (pure, TDD-first)
+
+ internal/upstream/limiter/limiter.go + tests. Acquire(ctx, queueTimeout) (release func(), err): admission TryAcquire fail → ErrQueueFull; run Acquire under timeout fail → release admission, ErrQueueTimeout (wrap ctx.Err to distinguish caller-cancel). Release closure bound to this instance so hot-swaps can’t double-release. Zero-config limiter = no-op passthrough.
+ Registry: map[serverName]*atomic.Pointer[Limiter] + global pointer; Update() swaps atomically; in-flight releases drain the old instance harmlessly.
+
+
+
+ Config fields — follow the 4-point checklist exactly
+
+ Global max_concurrent_requests, queue_size, queue_timeout near CallToolTimeout (config.go:173 ); per-server tri-state pointers copying the HealthCheckInterval pattern (config.go:521-530 ). Defaults 0 / 0 / 30s. Validation: non-negative; queue_size>0 requires max>0.
+ Explicit DetectConfigChanges clause for the three global fields (config_hotreload.go:102-104 model) — per-server fields are already covered by the Servers DeepEqual.
+ Env overrides MCPPROXY_MAX_CONCURRENT_REQUESTS / _QUEUE_SIZE / _QUEUE_TIMEOUT (loader.go:638-690 ); then make swagger (CI verifies) and docs/configuration.md.
+
+
+
+ Enforcement at the choke point
+
+ Manager owns the Registry; builds per-server limiters in AddServer/ApplyConfig, global in SetGlobalConfig.
+ managed/client.go:640 CallTool: resolve effective values (per-server pointer → global default); acquire per-server, then global (a slow upstream’s queue can’t pin global slots); defer release() both; delegate to coreClient. Do not wrap ListTools or Ping. Reconnect-on-use runs before CallTool, so it never holds a slot.
+
+
+
+ Shed semantics + observability
+
+ Map ErrQueueFull/ErrQueueTimeout via errors.Is in mcp.go (isError tool result, early-error pattern at :2074-2076) and httpapi (429 + Retry-After).
+ Activity event status rejected; rejection counter + queue-depth gauge next to existing tool-call metrics (observability/metrics.go:114-129 , bridge at observability_bridge.go:16-66 ). Follow-up: sustained saturation → degraded in the health calculator.
+
+
+
+ Tests + rollout
+
+ Unit: concurrent acquire/release, instant reject on full queue, queue-timeout, hot-swap under load, zero-config no-op. Hot-reload: extend global_config_hotreload_test.go .
+ E2E: slow stdio server with max=1/queue=1 — call 2 queues, call 3 sheds with isError; a code_execution case proving the bypass path is limited. go test -race, -tags server, ./scripts/test-api-e2e.sh .
+ Rollout: defaults 0/0 = pure opt-in, ships dark, no migration; hot-reload lets headless operators tune without restarts.
+
+
+
+
+ 05 Open decisions (maintainer input needed)
+
+ Defaults : keep 0 = unlimited everywhere (recommended, zero behavior change) vs an out-of-the-box stdio default (e.g. 5, mirroring mcp-go’s worker pool) that changes behavior on upgrade.
+ Protocol-level shed : is isError + REST 429 enough, or also emit a -32029 JSON-RPC error with data.retryAfter for non-tool methods (emerging convention; unverified how Claude Code/Cursor react mid-loop)?
+ Literal guarantee : should upstream tools/list and Ping ever count against a strict per-upstream limit for fragile single-threaded stdio servers (sketch excludes them)?
+ Activity vocabulary : new rejected status (touches Web UI filters, possibly telemetry schema) vs reusing error with a distinguishing code.
+ Per-user fairness (server edition): acceptable for v1 that one noisy user can occupy a FIFO queue, or reserve a (user,server) key shape in the Registry API now, ahead of Spec 074?
+ queue_timeout scope : per-server override for symmetry (as sketched) or global-only for a smaller config surface?
+ Health integration : saturation → degraded in v1 or follow-up?
+
+
+
+
diff --git a/frontend/src/components/ActivityWidget.vue b/frontend/src/components/ActivityWidget.vue
index 0b469d54a..11756f806 100644
--- a/frontend/src/components/ActivityWidget.vue
+++ b/frontend/src/components/ActivityWidget.vue
@@ -143,7 +143,9 @@ const getStatusBadgeClass = (status: string): string => {
const statusClasses: Record = {
'success': 'badge-success',
'error': 'badge-error',
- 'blocked': 'badge-warning'
+ 'blocked': 'badge-warning',
+ // Spec 093: shed by a concurrency limit (backpressure, not an upstream fault).
+ 'rejected': 'badge-info'
}
return statusClasses[status] || 'badge-ghost'
}
diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts
index 3f41539d8..3c317b2b6 100644
--- a/frontend/src/types/api.ts
+++ b/frontend/src/types/api.ts
@@ -511,6 +511,7 @@ export interface UsageToolStat {
errors: number
error_rate: number
blocked: number
+ rejected: number // Spec 093: shed by a concurrency limit; never executed
total_resp_bytes: number
avg_resp_bytes: number | null // null when only legacy 0-byte calls exist
total_req_bytes: number
@@ -548,7 +549,7 @@ export interface UsageAggregateResponse {
export type UsageWindow = '24h' | '7d' | 'all'
export type UsageSort = 'calls' | 'resp_bytes' | 'error_rate' | 'p95'
-export type UsageStatus = 'success' | 'error' | 'blocked'
+export type UsageStatus = 'success' | 'error' | 'blocked' | 'rejected'
export interface ToolCallRecord {
id: string
@@ -726,7 +727,17 @@ export type ActivityType =
export type ActivitySource = 'mcp' | 'cli' | 'api'
-export type ActivityStatus = 'success' | 'error' | 'blocked'
+/**
+ * Closed activity-status vocabulary. 'rejected' (Spec 093) means the call was
+ * shed by a concurrency limit before it reached the upstream — proxy
+ * backpressure, not an upstream failure.
+ */
+export type ActivityStatus = 'success' | 'error' | 'blocked' | 'rejected'
+
+/** Spec 093: machine-readable cause of a rejection (activity metadata). */
+export type RejectionReason = 'queue_full' | 'queue_timeout'
+/** Spec 093: which limiter tier shed the call (activity metadata). */
+export type RejectionScope = 'server' | 'global'
export interface ActivityRecord {
id: string
@@ -780,6 +791,7 @@ export interface ActivitySummaryResponse {
success_count: number
error_count: number
blocked_count: number
+ rejected_count: number
top_servers?: ActivityTopServer[]
top_tools?: ActivityTopTool[]
start_time: string
diff --git a/frontend/src/types/contracts.ts b/frontend/src/types/contracts.ts
index 2d54819d3..da6b25662 100644
--- a/frontend/src/types/contracts.ts
+++ b/frontend/src/types/contracts.ts
@@ -47,6 +47,23 @@ export interface HealthStatus {
action?: HealthAction;
}
+// 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';
+
export interface Server {
id: string;
name: string;
@@ -80,6 +97,13 @@ export interface Server {
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 {
diff --git a/frontend/src/utils/activity.ts b/frontend/src/utils/activity.ts
index 4b4613a9f..1f0e809d4 100644
--- a/frontend/src/utils/activity.ts
+++ b/frontend/src/utils/activity.ts
@@ -23,14 +23,18 @@ const typeIcons: Record = {
const statusLabels: Record = {
'success': 'Success',
'error': 'Error',
- 'blocked': 'Blocked'
+ 'blocked': 'Blocked',
+ // Spec 093: shed by a concurrency limit before reaching the upstream.
+ 'rejected': 'Rejected'
}
// Status badge CSS classes (DaisyUI)
const statusClasses: Record = {
'success': 'badge-success',
'error': 'badge-error',
- 'blocked': 'badge-warning'
+ 'blocked': 'badge-warning',
+ // Distinct from both error (upstream fault) and blocked (policy): backpressure.
+ 'rejected': 'badge-info'
}
// Intent operation type icons
diff --git a/frontend/src/views/Activity.vue b/frontend/src/views/Activity.vue
index d78eef939..c3c7754b3 100644
--- a/frontend/src/views/Activity.vue
+++ b/frontend/src/views/Activity.vue
@@ -72,6 +72,16 @@
Blocked
{{ summary.blocked_count }}
+
+ Rejected
+ {{ summary.rejected_count }}
+
@@ -146,6 +156,7 @@
Success
Error
Blocked
+ Rejected
@@ -1210,7 +1221,8 @@ const formatStatus = (status: string): string => {
const statusLabels: Record = {
'success': 'Success',
'error': 'Error',
- 'blocked': 'Blocked'
+ 'blocked': 'Blocked',
+ 'rejected': 'Rejected'
}
return statusLabels[status] || status
}
@@ -1219,7 +1231,9 @@ const getStatusBadgeClass = (status: string): string => {
const statusClasses: Record = {
'success': 'badge-success',
'error': 'badge-error',
- 'blocked': 'badge-warning'
+ 'blocked': 'badge-warning',
+ // Spec 093: shed by a concurrency limit (backpressure, not an upstream fault).
+ 'rejected': 'badge-info'
}
return statusClasses[status] || 'badge-ghost'
}
diff --git a/frontend/src/views/Usage.vue b/frontend/src/views/Usage.vue
index f4d99425e..fe075f1b7 100644
--- a/frontend/src/views/Usage.vue
+++ b/frontend/src/views/Usage.vue
@@ -20,6 +20,7 @@
Success
Errors
Blocked
+ Rejected
diff --git a/internal/config/concurrency.go b/internal/config/concurrency.go
new file mode 100644
index 000000000..e62ce2405
--- /dev/null
+++ b/internal/config/concurrency.go
@@ -0,0 +1,213 @@
+package config
+
+import (
+ "fmt"
+ "time"
+)
+
+// defaultQueueTimeout is the wait budget applied to an ACTIVE limiter scope
+// that does not configure queue_timeout explicitly (spec 093, FR-020). It is
+// deliberately not part of DefaultConfig: an unset key must keep a config
+// byte-identical to a pre-feature one, and the value only matters once a
+// limiter exists.
+const defaultQueueTimeout = 30 * time.Second
+
+// ConcurrencyDefaults is the per-server DEFAULT SET — scope (b) of FR-020.
+// Every setting is tri-state (nil = not configured); the same three settings
+// exist verbatim on ServerConfig as per-server overrides and at the top level
+// as the global aggregate limiter.
+type ConcurrencyDefaults struct {
+ MaxConcurrentRequests *int `json:"max_concurrent_requests,omitempty" mapstructure:"max_concurrent_requests"`
+ QueueSize *int `json:"queue_size,omitempty" mapstructure:"queue_size"`
+ QueueTimeout *Duration `json:"queue_timeout,omitempty" mapstructure:"queue_timeout" swaggertype:"string"`
+}
+
+// ResolvedConcurrency is one scope's settings after tri-state resolution. It is
+// the shape the limiter registry consumes.
+type ResolvedConcurrency struct {
+ MaxConcurrentRequests int
+ QueueSize int
+ QueueTimeout time.Duration
+}
+
+// Enabled reports whether this scope actually caps concurrency.
+func (r ResolvedConcurrency) Enabled() bool { return r.MaxConcurrentRequests > 0 }
+
+// resolveConcurrency applies the "override → default set → unset" precedence
+// per setting, then fills in the queue-timeout default for an active limiter.
+func resolveConcurrency(overrideMax, defaultMax *int, overrideQueue, defaultQueue *int, overrideTimeout, defaultTimeout *Duration) ResolvedConcurrency {
+ pick := func(override, def *int) int {
+ if override != nil {
+ return *override
+ }
+ if def != nil {
+ return *def
+ }
+ return 0
+ }
+ pickDur := func(override, def *Duration) time.Duration {
+ if override != nil {
+ return override.Duration()
+ }
+ if def != nil {
+ return def.Duration()
+ }
+ return 0
+ }
+
+ res := ResolvedConcurrency{
+ MaxConcurrentRequests: pick(overrideMax, defaultMax),
+ QueueSize: pick(overrideQueue, defaultQueue),
+ QueueTimeout: pickDur(overrideTimeout, defaultTimeout),
+ }
+ if !res.Enabled() {
+ // A disabled scope has no queue and no wait budget: the pending
+ // capacity of a limiter that does not exist is meaningless, and this
+ // is what makes an explicit `max_concurrent_requests: 0` a complete
+ // opt-out for a server that inherits a queue size from the default set.
+ res.QueueSize = 0
+ res.QueueTimeout = 0
+ return res
+ }
+ if res.QueueTimeout <= 0 {
+ res.QueueTimeout = defaultQueueTimeout
+ }
+ return res
+}
+
+// ResolveGlobalConcurrency resolves the global aggregate limiter — scope (a) of
+// FR-020. Absent or 0 max = no global limiter.
+func (c *Config) ResolveGlobalConcurrency() ResolvedConcurrency {
+ if c == nil {
+ return ResolvedConcurrency{}
+ }
+ return resolveConcurrency(c.MaxConcurrentRequests, nil, c.QueueSize, nil, c.QueueTimeout, nil)
+}
+
+// ResolveServerConcurrency resolves a server's per-server limiter — scopes (b)
+// and (c) of FR-020: per-server override → per-server default set → unset. The
+// global aggregate limiter is never an inheritance source here; it applies on
+// top of the resolved value (effective concurrency = min of the two).
+func (c *Config) ResolveServerConcurrency(sc *ServerConfig) ResolvedConcurrency {
+ if c == nil {
+ return ResolvedConcurrency{}
+ }
+ var defMax, defQueue *int
+ var defTimeout *Duration
+ if c.ServerConcurrencyDefaults != nil {
+ defMax = c.ServerConcurrencyDefaults.MaxConcurrentRequests
+ defQueue = c.ServerConcurrencyDefaults.QueueSize
+ defTimeout = c.ServerConcurrencyDefaults.QueueTimeout
+ }
+ var srvMax, srvQueue *int
+ var srvTimeout *Duration
+ if sc != nil {
+ srvMax = sc.MaxConcurrentRequests
+ srvQueue = sc.QueueSize
+ srvTimeout = sc.QueueTimeout
+ }
+ return resolveConcurrency(srvMax, defMax, srvQueue, defQueue, srvTimeout, defTimeout)
+}
+
+// validateConcurrencyScope implements FR-023 for one resolved scope: negative
+// values are rejected, and a positive queue size is rejected when the scope's
+// concurrency limit resolves to disabled/unlimited. scopeLabel names the scope
+// in the error field (e.g. "" for the global aggregate,
+// "server_concurrency_defaults", or "mcpServers[0] (db)").
+func validateConcurrencyScope(fieldPrefix, scopeName string, maxPtr, queuePtr *int, timeoutPtr *Duration, resolvedMax, resolvedQueue int, explicitlyDisabled bool) []ValidationError {
+ var errs []ValidationError
+ field := func(name string) string {
+ if fieldPrefix == "" {
+ return name
+ }
+ return fieldPrefix + "." + name
+ }
+
+ if maxPtr != nil && *maxPtr < 0 {
+ errs = append(errs, ValidationError{
+ Field: field("max_concurrent_requests"),
+ Message: fmt.Sprintf("cannot be negative (%s): use 0 to disable the limiter or a positive cap", scopeName),
+ })
+ }
+ if queuePtr != nil && *queuePtr < 0 {
+ errs = append(errs, ValidationError{
+ Field: field("queue_size"),
+ Message: fmt.Sprintf("cannot be negative (%s): use 0 for no pending capacity or a positive queue length", scopeName),
+ })
+ }
+ if timeoutPtr != nil && timeoutPtr.Duration() < 0 {
+ errs = append(errs, ValidationError{
+ Field: field("queue_timeout"),
+ Message: fmt.Sprintf("cannot be negative (%s): use a positive duration such as \"30s\"", scopeName),
+ })
+ }
+
+ // A queue attached to a limit that is not active can never admit anything.
+ // Skipped when the scope explicitly opted out with max_concurrent_requests:
+ // 0 — that is the documented way to disable a server that would otherwise
+ // inherit a queue size from the per-server default set (FR-020(c)).
+ if resolvedQueue > 0 && resolvedMax <= 0 && !explicitlyDisabled {
+ errs = append(errs, ValidationError{
+ Field: field("queue_size"),
+ Message: fmt.Sprintf("queue_size %d requires max_concurrent_requests > 0 (%s): set a positive max_concurrent_requests, or queue_size 0 to remove the queue",
+ resolvedQueue, scopeName),
+ })
+ }
+ return errs
+}
+
+// validateConcurrency validates all three scopes of FR-020 after resolution.
+func (c *Config) validateConcurrency() []ValidationError {
+ var errs []ValidationError
+
+ // (a) global aggregate limiter.
+ global := c.ResolveGlobalConcurrency()
+ globalQueue := global.QueueSize
+ if !global.Enabled() && c.QueueSize != nil {
+ globalQueue = *c.QueueSize
+ }
+ errs = append(errs, validateConcurrencyScope("", "global aggregate limiter",
+ c.MaxConcurrentRequests, c.QueueSize, c.QueueTimeout,
+ global.MaxConcurrentRequests, globalQueue, false)...)
+
+ // (b) per-server default set. Resolved standalone: it is what a server with
+ // no overrides inherits.
+ if d := c.ServerConcurrencyDefaults; d != nil {
+ resolved := resolveConcurrency(nil, d.MaxConcurrentRequests, nil, d.QueueSize, nil, d.QueueTimeout)
+ queue := resolved.QueueSize
+ if !resolved.Enabled() && d.QueueSize != nil {
+ queue = *d.QueueSize
+ }
+ errs = append(errs, validateConcurrencyScope("server_concurrency_defaults", "per-server default set",
+ d.MaxConcurrentRequests, d.QueueSize, d.QueueTimeout,
+ resolved.MaxConcurrentRequests, queue, false)...)
+ }
+
+ // (c) per-server overrides, validated on the RESOLVED values so an
+ // inherited limit satisfies an explicit queue size (and vice versa).
+ for i, server := range c.Servers {
+ if server == nil {
+ continue
+ }
+ resolved := c.ResolveServerConcurrency(server)
+ queue := resolved.QueueSize
+ if !resolved.Enabled() {
+ // Re-derive the pre-disable queue value so an orphaned queue is
+ // still reported (unless the server explicitly opted out).
+ switch {
+ case server.QueueSize != nil:
+ queue = *server.QueueSize
+ case c.ServerConcurrencyDefaults != nil && c.ServerConcurrencyDefaults.QueueSize != nil:
+ queue = *c.ServerConcurrencyDefaults.QueueSize
+ }
+ }
+ explicitOptOut := server.MaxConcurrentRequests != nil && *server.MaxConcurrentRequests == 0
+ errs = append(errs, validateConcurrencyScope(
+ fmt.Sprintf("mcpServers[%d]", i),
+ fmt.Sprintf("server %q", server.Name),
+ server.MaxConcurrentRequests, server.QueueSize, server.QueueTimeout,
+ resolved.MaxConcurrentRequests, queue, explicitOptOut)...)
+ }
+
+ return errs
+}
diff --git a/internal/config/concurrency_test.go b/internal/config/concurrency_test.go
new file mode 100644
index 000000000..041deb743
--- /dev/null
+++ b/internal/config/concurrency_test.go
@@ -0,0 +1,409 @@
+package config
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+ "time"
+)
+
+func intPtr(v int) *int { return &v }
+
+// TestResolveGlobalConcurrency covers FR-020(a): the global aggregate limiter
+// carries its own three values; absent/0 max disables it; the queue timeout
+// falls back to 30s only while the limiter is active.
+func TestResolveGlobalConcurrency(t *testing.T) {
+ cases := []struct {
+ name string
+ cfg Config
+ want ResolvedConcurrency
+ isOff bool
+ }{
+ {
+ name: "absent → off",
+ cfg: Config{},
+ want: ResolvedConcurrency{},
+ isOff: true,
+ },
+ {
+ name: "explicit 0 → off (queue is meaningless without a limiter)",
+ cfg: Config{MaxConcurrentRequests: intPtr(0), QueueSize: intPtr(4)},
+ want: ResolvedConcurrency{},
+ isOff: true,
+ },
+ {
+ name: "max set → default queue timeout applies",
+ cfg: Config{MaxConcurrentRequests: intPtr(10)},
+ want: ResolvedConcurrency{MaxConcurrentRequests: 10, QueueSize: 0, QueueTimeout: 30 * time.Second},
+ },
+ {
+ name: "all values explicit",
+ cfg: Config{MaxConcurrentRequests: intPtr(10), QueueSize: intPtr(20), QueueTimeout: durPtr(5 * time.Second)},
+ want: ResolvedConcurrency{MaxConcurrentRequests: 10, QueueSize: 20, QueueTimeout: 5 * time.Second},
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := tc.cfg.ResolveGlobalConcurrency()
+ if got != tc.want {
+ t.Fatalf("ResolveGlobalConcurrency = %+v, want %+v", got, tc.want)
+ }
+ if got.Enabled() == tc.isOff {
+ t.Fatalf("Enabled() = %v, want %v", got.Enabled(), !tc.isOff)
+ }
+ })
+ }
+}
+
+// TestResolveServerConcurrency covers FR-020(b)+(c): each setting is tri-state
+// per server — absent inherits the per-server DEFAULT SET (never the global
+// aggregate), explicit 0 disables that setting, positive overrides.
+func TestResolveServerConcurrency(t *testing.T) {
+ cases := []struct {
+ name string
+ defaults *ConcurrencyDefaults
+ global *int
+ server *ServerConfig
+ want ResolvedConcurrency
+ }{
+ {
+ name: "nothing configured → off",
+ want: ResolvedConcurrency{},
+ },
+ {
+ name: "global aggregate is NOT a per-server inheritance source",
+ global: intPtr(50),
+ server: &ServerConfig{Name: "s"},
+ want: ResolvedConcurrency{},
+ },
+ {
+ name: "inherits the default set",
+ defaults: &ConcurrencyDefaults{MaxConcurrentRequests: intPtr(5), QueueSize: intPtr(10), QueueTimeout: durPtr(3 * time.Second)},
+ server: &ServerConfig{Name: "s"},
+ want: ResolvedConcurrency{MaxConcurrentRequests: 5, QueueSize: 10, QueueTimeout: 3 * time.Second},
+ },
+ {
+ name: "per-server override wins per setting",
+ defaults: &ConcurrencyDefaults{MaxConcurrentRequests: intPtr(5), QueueSize: intPtr(10), QueueTimeout: durPtr(3 * time.Second)},
+ server: &ServerConfig{Name: "s", MaxConcurrentRequests: intPtr(1)},
+ want: ResolvedConcurrency{MaxConcurrentRequests: 1, QueueSize: 10, QueueTimeout: 3 * time.Second},
+ },
+ {
+ name: "explicit 0 opts the server out of the limit",
+ defaults: &ConcurrencyDefaults{MaxConcurrentRequests: intPtr(5), QueueSize: intPtr(10)},
+ server: &ServerConfig{Name: "s", MaxConcurrentRequests: intPtr(0)},
+ want: ResolvedConcurrency{MaxConcurrentRequests: 0, QueueSize: 0, QueueTimeout: 0},
+ },
+ {
+ name: "explicit queue_size 0 means shed at the cap",
+ defaults: &ConcurrencyDefaults{MaxConcurrentRequests: intPtr(5), QueueSize: intPtr(10)},
+ server: &ServerConfig{Name: "s", QueueSize: intPtr(0)},
+ want: ResolvedConcurrency{MaxConcurrentRequests: 5, QueueSize: 0, QueueTimeout: 30 * time.Second},
+ },
+ {
+ name: "default queue timeout applied when the limit is active",
+ defaults: &ConcurrencyDefaults{MaxConcurrentRequests: intPtr(5)},
+ server: &ServerConfig{Name: "s"},
+ want: ResolvedConcurrency{MaxConcurrentRequests: 5, QueueTimeout: 30 * time.Second},
+ },
+ {
+ name: "server-only configuration (no defaults block)",
+ server: &ServerConfig{Name: "s", MaxConcurrentRequests: intPtr(2), QueueSize: intPtr(3), QueueTimeout: durPtr(time.Second)},
+ want: ResolvedConcurrency{MaxConcurrentRequests: 2, QueueSize: 3, QueueTimeout: time.Second},
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ c := &Config{ServerConcurrencyDefaults: tc.defaults, MaxConcurrentRequests: tc.global}
+ got := c.ResolveServerConcurrency(tc.server)
+ if got != tc.want {
+ t.Fatalf("ResolveServerConcurrency = %+v, want %+v", got, tc.want)
+ }
+ })
+ }
+}
+
+func TestResolveServerConcurrencyNilServer(t *testing.T) {
+ c := &Config{ServerConcurrencyDefaults: &ConcurrencyDefaults{MaxConcurrentRequests: intPtr(4)}}
+ got := c.ResolveServerConcurrency(nil)
+ if got.MaxConcurrentRequests != 4 {
+ t.Fatalf("nil server must resolve the default set, got %+v", got)
+ }
+}
+
+// TestValidateConcurrency covers FR-023: per resolved scope, reject negatives
+// and a positive queue size whose limit resolves to disabled — with an error
+// naming the offending scope and field.
+func TestValidateConcurrency(t *testing.T) {
+ cases := []struct {
+ name string
+ mutate func(*Config)
+ wantField string // "" = must validate cleanly
+ }{
+ {"defaults are valid", func(_ *Config) {}, ""},
+ {"global limits valid", func(c *Config) {
+ c.MaxConcurrentRequests = intPtr(10)
+ c.QueueSize = intPtr(20)
+ c.QueueTimeout = durPtr(10 * time.Second)
+ }, ""},
+ {"negative global max", func(c *Config) { c.MaxConcurrentRequests = intPtr(-1) }, "max_concurrent_requests"},
+ {"negative global queue size", func(c *Config) {
+ c.MaxConcurrentRequests = intPtr(2)
+ c.QueueSize = intPtr(-5)
+ }, "queue_size"},
+ {"negative global queue timeout", func(c *Config) {
+ c.MaxConcurrentRequests = intPtr(2)
+ c.QueueTimeout = durPtr(-time.Second)
+ }, "queue_timeout"},
+ {"global queue without a limit", func(c *Config) { c.QueueSize = intPtr(5) }, "queue_size"},
+ {"defaults queue without a limit", func(c *Config) {
+ c.ServerConcurrencyDefaults = &ConcurrencyDefaults{QueueSize: intPtr(5)}
+ }, "server_concurrency_defaults.queue_size"},
+ {"defaults negative max", func(c *Config) {
+ c.ServerConcurrencyDefaults = &ConcurrencyDefaults{MaxConcurrentRequests: intPtr(-2)}
+ }, "server_concurrency_defaults.max_concurrent_requests"},
+ {"per-server negative max", func(c *Config) {
+ c.Servers = []*ServerConfig{{Name: "db", MaxConcurrentRequests: intPtr(-1)}}
+ }, "max_concurrent_requests"},
+ {"per-server queue without a resolved limit", func(c *Config) {
+ c.Servers = []*ServerConfig{{Name: "db", QueueSize: intPtr(3)}}
+ }, "queue_size"},
+ {"per-server queue inherits a limit from the defaults → valid", func(c *Config) {
+ c.ServerConcurrencyDefaults = &ConcurrencyDefaults{MaxConcurrentRequests: intPtr(4)}
+ c.Servers = []*ServerConfig{{Name: "db", QueueSize: intPtr(3)}}
+ }, ""},
+ {"explicit per-server opt-out with inherited queue → valid", func(c *Config) {
+ c.ServerConcurrencyDefaults = &ConcurrencyDefaults{MaxConcurrentRequests: intPtr(4), QueueSize: intPtr(8)}
+ c.Servers = []*ServerConfig{{Name: "db", MaxConcurrentRequests: intPtr(0)}}
+ }, ""},
+ {"per-server negative queue timeout", func(c *Config) {
+ c.Servers = []*ServerConfig{{Name: "db", MaxConcurrentRequests: intPtr(1), QueueTimeout: durPtr(-time.Second)}}
+ }, "queue_timeout"},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ c := DefaultConfig()
+ c.Servers = nil
+ tc.mutate(c)
+ errs := c.ValidateDetailed()
+
+ var matched []string
+ for _, e := range errs {
+ if containsAny(e.Field, "max_concurrent_requests", "queue_size", "queue_timeout") {
+ matched = append(matched, e.Field+": "+e.Message)
+ }
+ }
+ if tc.wantField == "" {
+ if len(matched) > 0 {
+ t.Fatalf("unexpected concurrency validation errors: %v", matched)
+ }
+ return
+ }
+ if len(matched) == 0 {
+ t.Fatalf("expected a validation error naming %q, got none (all errors: %+v)", tc.wantField, errs)
+ }
+ found := false
+ for _, m := range matched {
+ if strings.Contains(m, tc.wantField) {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("expected an error naming %q, got %v", tc.wantField, matched)
+ }
+ })
+ }
+}
+
+// TestValidateConcurrencyErrorNamesServer: FR-023 wants actionable messages
+// that identify the offending scope, including which server.
+func TestValidateConcurrencyErrorNamesServer(t *testing.T) {
+ c := DefaultConfig()
+ c.Servers = []*ServerConfig{{Name: "fragile-db", QueueSize: intPtr(3)}}
+ errs := c.ValidateDetailed()
+ found := false
+ for _, e := range errs {
+ if strings.Contains(e.Field, "queue_size") && strings.Contains(e.Message, "max_concurrent_requests") {
+ found = true
+ if !strings.Contains(e.Field, "fragile-db") && !strings.Contains(e.Message, "fragile-db") {
+ t.Fatalf("error does not identify the server: field=%q msg=%q", e.Field, e.Message)
+ }
+ }
+ }
+ if !found {
+ t.Fatalf("expected a queue_size validation error, got %+v", errs)
+ }
+}
+
+// TestConcurrencyJSONRoundTripAbsent: an untouched config must serialize
+// byte-identically to before the feature (no new keys), and a configured one
+// must round-trip.
+func TestConcurrencyJSONRoundTripAbsent(t *testing.T) {
+ c := &Config{Servers: []*ServerConfig{{Name: "s"}}}
+ data, err := json.Marshal(c)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ for _, key := range []string{"max_concurrent_requests", "queue_size", "queue_timeout", "server_concurrency_defaults"} {
+ if strings.Contains(string(data), key) {
+ t.Fatalf("absent concurrency settings must not be serialized, found %q in %s", key, data)
+ }
+ }
+}
+
+func TestConcurrencyJSONRoundTrip(t *testing.T) {
+ raw := `{
+ "max_concurrent_requests": 20,
+ "queue_size": 40,
+ "queue_timeout": "15s",
+ "server_concurrency_defaults": {"max_concurrent_requests": 5, "queue_size": 10, "queue_timeout": "10s"},
+ "mcpServers": [{"name": "db", "max_concurrent_requests": 1, "queue_size": 2, "queue_timeout": "5s"}]
+ }`
+ var c Config
+ if err := json.Unmarshal([]byte(raw), &c); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if c.MaxConcurrentRequests == nil || *c.MaxConcurrentRequests != 20 {
+ t.Fatalf("global max = %v", c.MaxConcurrentRequests)
+ }
+ if c.QueueSize == nil || *c.QueueSize != 40 {
+ t.Fatalf("global queue size = %v", c.QueueSize)
+ }
+ if c.QueueTimeout == nil || c.QueueTimeout.Duration() != 15*time.Second {
+ t.Fatalf("global queue timeout = %v", c.QueueTimeout)
+ }
+ if c.ServerConcurrencyDefaults == nil || *c.ServerConcurrencyDefaults.MaxConcurrentRequests != 5 {
+ t.Fatalf("defaults = %+v", c.ServerConcurrencyDefaults)
+ }
+ got := c.ResolveServerConcurrency(c.Servers[0])
+ want := ResolvedConcurrency{MaxConcurrentRequests: 1, QueueSize: 2, QueueTimeout: 5 * time.Second}
+ if got != want {
+ t.Fatalf("resolved = %+v, want %+v", got, want)
+ }
+
+ out, err := json.Marshal(&c)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ var back Config
+ if err := json.Unmarshal(out, &back); err != nil {
+ t.Fatalf("re-unmarshal: %v", err)
+ }
+ if back.ResolveServerConcurrency(back.Servers[0]) != want {
+ t.Fatalf("round-trip lost per-server concurrency: %+v", back.Servers[0])
+ }
+ if back.ResolveGlobalConcurrency() != (ResolvedConcurrency{MaxConcurrentRequests: 20, QueueSize: 40, QueueTimeout: 15 * time.Second}) {
+ t.Fatalf("round-trip lost global concurrency: %+v", back.ResolveGlobalConcurrency())
+ }
+}
+
+// TestCopyServerConfigCopiesConcurrencyPointers guards the copy-on-write path:
+// the tri-state pointers must be copied by value, not shared.
+func TestCopyServerConfigCopiesConcurrencyPointers(t *testing.T) {
+ src := &ServerConfig{Name: "s", MaxConcurrentRequests: intPtr(2), QueueSize: intPtr(4), QueueTimeout: durPtr(time.Second)}
+ dst := CopyServerConfig(src)
+ if dst.MaxConcurrentRequests == nil || *dst.MaxConcurrentRequests != 2 {
+ t.Fatalf("max not copied: %+v", dst.MaxConcurrentRequests)
+ }
+ if dst.QueueSize == nil || *dst.QueueSize != 4 {
+ t.Fatalf("queue size not copied: %+v", dst.QueueSize)
+ }
+ if dst.QueueTimeout == nil || dst.QueueTimeout.Duration() != time.Second {
+ t.Fatalf("queue timeout not copied: %+v", dst.QueueTimeout)
+ }
+ *src.MaxConcurrentRequests = 9
+ if *dst.MaxConcurrentRequests == 9 {
+ t.Fatal("MaxConcurrentRequests pointer is shared, not copied by value")
+ }
+ *src.QueueSize = 9
+ if *dst.QueueSize == 9 {
+ t.Fatal("QueueSize pointer is shared, not copied by value")
+ }
+ *src.QueueTimeout = Duration(time.Hour)
+ if dst.QueueTimeout.Duration() == time.Hour {
+ t.Fatal("QueueTimeout pointer is shared, not copied by value")
+ }
+}
+
+// TestMergeServerConfigConcurrencyPatch: a PATCH that sets the per-server
+// limits must survive the merge (and leave them untouched when absent).
+func TestMergeServerConfigConcurrencyPatch(t *testing.T) {
+ base := &ServerConfig{Name: "s", MaxConcurrentRequests: intPtr(2)}
+ patch := &ServerConfig{QueueSize: intPtr(6), QueueTimeout: durPtr(9 * time.Second)}
+ merged, _, err := MergeServerConfig(base, patch, DefaultMergeOptions())
+ if err != nil {
+ t.Fatalf("merge: %v", err)
+ }
+ if merged.MaxConcurrentRequests == nil || *merged.MaxConcurrentRequests != 2 {
+ t.Fatalf("base max lost: %+v", merged.MaxConcurrentRequests)
+ }
+ if merged.QueueSize == nil || *merged.QueueSize != 6 {
+ t.Fatalf("patched queue size = %+v", merged.QueueSize)
+ }
+ if merged.QueueTimeout == nil || merged.QueueTimeout.Duration() != 9*time.Second {
+ t.Fatalf("patched queue timeout = %+v", merged.QueueTimeout)
+ }
+}
+
+// TestConcurrencyEnvOverrides covers FR-022: the GLOBAL aggregate limiter's
+// three settings are overridable via MCPPROXY_* env vars (the per-server
+// default set and per-server overrides are file/API-configured only).
+func TestConcurrencyEnvOverrides(t *testing.T) {
+ t.Run("all three applied", func(t *testing.T) {
+ t.Setenv("MCPPROXY_MAX_CONCURRENT_REQUESTS", "12")
+ t.Setenv("MCPPROXY_QUEUE_SIZE", "24")
+ t.Setenv("MCPPROXY_QUEUE_TIMEOUT", "45s")
+
+ cfg := DefaultConfig()
+ applyTLSEnvOverrides(cfg)
+
+ got := cfg.ResolveGlobalConcurrency()
+ want := ResolvedConcurrency{MaxConcurrentRequests: 12, QueueSize: 24, QueueTimeout: 45 * time.Second}
+ if got != want {
+ t.Fatalf("resolved = %+v, want %+v", got, want)
+ }
+ })
+
+ t.Run("env wins over the file value", func(t *testing.T) {
+ t.Setenv("MCPPROXY_MAX_CONCURRENT_REQUESTS", "3")
+ cfg := DefaultConfig()
+ cfg.MaxConcurrentRequests = intPtr(50)
+ applyTLSEnvOverrides(cfg)
+ if cfg.MaxConcurrentRequests == nil || *cfg.MaxConcurrentRequests != 3 {
+ t.Fatalf("max = %v, want 3", cfg.MaxConcurrentRequests)
+ }
+ })
+
+ t.Run("explicit 0 disables the global limiter", func(t *testing.T) {
+ t.Setenv("MCPPROXY_MAX_CONCURRENT_REQUESTS", "0")
+ cfg := DefaultConfig()
+ cfg.MaxConcurrentRequests = intPtr(50)
+ applyTLSEnvOverrides(cfg)
+ if cfg.ResolveGlobalConcurrency().Enabled() {
+ t.Fatal("MCPPROXY_MAX_CONCURRENT_REQUESTS=0 must disable the global limiter")
+ }
+ })
+
+ t.Run("unset env leaves the config untouched", func(t *testing.T) {
+ cfg := DefaultConfig()
+ cfg.MaxConcurrentRequests = intPtr(7)
+ applyTLSEnvOverrides(cfg)
+ if cfg.MaxConcurrentRequests == nil || *cfg.MaxConcurrentRequests != 7 {
+ t.Fatalf("max = %v, want 7", cfg.MaxConcurrentRequests)
+ }
+ if cfg.QueueSize != nil || cfg.QueueTimeout != nil {
+ t.Fatalf("unset env must not materialize values: %v %v", cfg.QueueSize, cfg.QueueTimeout)
+ }
+ })
+
+ t.Run("malformed values are ignored", func(t *testing.T) {
+ t.Setenv("MCPPROXY_MAX_CONCURRENT_REQUESTS", "lots")
+ t.Setenv("MCPPROXY_QUEUE_TIMEOUT", "soon")
+ cfg := DefaultConfig()
+ applyTLSEnvOverrides(cfg)
+ if cfg.MaxConcurrentRequests != nil || cfg.QueueTimeout != nil {
+ t.Fatalf("malformed env must be ignored: %v %v", cfg.MaxConcurrentRequests, cfg.QueueTimeout)
+ }
+ })
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index c551ebe3c..516434729 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -173,6 +173,26 @@ type Config struct {
CallToolTimeout Duration `json:"call_tool_timeout" mapstructure:"call-tool-timeout" swaggertype:"string"`
MaxResultSizeChars int `json:"max_result_size_chars,omitempty" mapstructure:"max-result-size-chars"` // Advertised on every tool as `_meta.anthropic/maxResultSizeChars`; raises Claude Code's inline-response ceiling from 50k to up to 500k chars. Set to 0 to disable.
+ // Concurrency limits (spec 093, GH #955). Scope (a) of FR-020: the GLOBAL
+ // AGGREGATE limiter — one proxy-wide cap on concurrently running upstream
+ // tool calls, with its own bounded wait queue. Tri-state pointers: absent =
+ // the limiter does not exist (default, zero behavior change); an explicit 0
+ // max also disables it; positive = that cap. This scope is NEVER a
+ // per-server inheritance source — per-server values come from
+ // ServerConcurrencyDefaults / the per-server overrides — but a server's
+ // effective concurrency is bounded by BOTH its own limiter and this one.
+ // Resolved by ResolveGlobalConcurrency; hot-reloadable; overridable via
+ // MCPPROXY_MAX_CONCURRENT_REQUESTS / _QUEUE_SIZE / _QUEUE_TIMEOUT (FR-022).
+ MaxConcurrentRequests *int `json:"max_concurrent_requests,omitempty" mapstructure:"max-concurrent-requests"`
+ QueueSize *int `json:"queue_size,omitempty" mapstructure:"queue-size"`
+ QueueTimeout *Duration `json:"queue_timeout,omitempty" mapstructure:"queue-timeout" swaggertype:"string"`
+
+ // ServerConcurrencyDefaults is scope (b) of FR-020: the blanket per-server
+ // default set inherited by every server that does not override a setting.
+ // Absent (the default) = no per-server limiting unless a server configures
+ // it explicitly. File/API-configured only — no env scheme (FR-022).
+ ServerConcurrencyDefaults *ConcurrencyDefaults `json:"server_concurrency_defaults,omitempty" mapstructure:"server-concurrency-defaults"`
+
// ToonOutput selects the TOON encoding mode for call_tool_* result text
// blocks (spec 084): "off" (default — responses byte-identical to
// pre-feature behavior), "adaptive" (encode only tabular-uniform payloads
@@ -529,6 +549,18 @@ type ServerConfig struct {
// channels/users) before responding to `initialize`.
InitTimeout *Duration `json:"init_timeout,omitempty" mapstructure:"init_timeout" swaggertype:"string"`
+ // Per-server concurrency overrides — scope (c) of FR-020 (spec 093, #955).
+ // Tri-state per setting, exactly like HealthCheckInterval: absent = inherit
+ // the per-server default set (server_concurrency_defaults), explicit 0 =
+ // disable that setting for this server (0 max = no per-server limiter at
+ // all; 0 queue_size = no pending capacity, shed immediately at the cap),
+ // positive = override. The global aggregate limiter is never inherited from
+ // here — it applies on top, so effective concurrency is min(per-server,
+ // global). Resolved by Config.ResolveServerConcurrency.
+ MaxConcurrentRequests *int `json:"max_concurrent_requests,omitempty" mapstructure:"max_concurrent_requests"`
+ QueueSize *int `json:"queue_size,omitempty" mapstructure:"queue_size"`
+ QueueTimeout *Duration `json:"queue_timeout,omitempty" mapstructure:"queue_timeout" swaggertype:"string"`
+
// ToonOutput overrides the global toon_output mode for this server's
// tools (spec 084, FR-001). Plain string, not a pointer: ""/absent =
// inherit the global value; "off"|"adaptive"|"always" = override ("off"
@@ -1989,6 +2021,9 @@ func (c *Config) ValidateDetailed() []ValidationError {
errors = append(errors, *e)
}
+ // Concurrency limits, all three scopes after resolution (spec 093, FR-023).
+ errors = append(errors, c.validateConcurrency()...)
+
// Validate code execution configuration (0 means use default)
if c.CodeExecutionTimeoutMs != 0 && (c.CodeExecutionTimeoutMs < 1 || c.CodeExecutionTimeoutMs > 600000) {
errors = append(errors, ValidationError{
diff --git a/internal/config/loader.go b/internal/config/loader.go
index 04ec58c7c..766968099 100644
--- a/internal/config/loader.go
+++ b/internal/config/loader.go
@@ -9,6 +9,7 @@ import (
"os"
"os/exec"
"path/filepath"
+ "strconv"
"strings"
"time"
@@ -688,4 +689,33 @@ func applyTLSEnvOverrides(cfg *Config) {
if value := os.Getenv("MCPPROXY_TOOL_RESPONSE_MODE"); value != "" {
cfg.ToolResponseMode = value
}
+
+ // Override the GLOBAL aggregate concurrency limiter from environment
+ // (spec 093 FR-022, GH #955). Only this scope has an env scheme: the
+ // per-server default set and per-server overrides are file/API-configured.
+ // An explicit 0 is meaningful (it disables the limiter), so the value is
+ // materialized as a pointer; malformed values are warned about and ignored
+ // so a typo cannot silently reshape the proxy's admission behavior.
+ if value := os.Getenv("MCPPROXY_MAX_CONCURRENT_REQUESTS"); value != "" {
+ if n, err := strconv.Atoi(value); err == nil && n >= 0 {
+ cfg.MaxConcurrentRequests = &n
+ } else {
+ fmt.Fprintf(os.Stderr, "WARN: Ignoring invalid MCPPROXY_MAX_CONCURRENT_REQUESTS=%q (want a non-negative integer)\n", value)
+ }
+ }
+ if value := os.Getenv("MCPPROXY_QUEUE_SIZE"); value != "" {
+ if n, err := strconv.Atoi(value); err == nil && n >= 0 {
+ cfg.QueueSize = &n
+ } else {
+ fmt.Fprintf(os.Stderr, "WARN: Ignoring invalid MCPPROXY_QUEUE_SIZE=%q (want a non-negative integer)\n", value)
+ }
+ }
+ if value := os.Getenv("MCPPROXY_QUEUE_TIMEOUT"); value != "" {
+ if d, err := time.ParseDuration(value); err == nil && d >= 0 {
+ qt := Duration(d)
+ cfg.QueueTimeout = &qt
+ } else {
+ fmt.Fprintf(os.Stderr, "WARN: Ignoring invalid MCPPROXY_QUEUE_TIMEOUT=%q (want a duration such as \"30s\")\n", value)
+ }
+ }
}
diff --git a/internal/config/merge.go b/internal/config/merge.go
index 9115c5ff3..d5e96a1d2 100644
--- a/internal/config/merge.go
+++ b/internal/config/merge.go
@@ -340,6 +340,31 @@ func MergeServerConfig(base, patch *ServerConfig, opts MergeOptions) (*ServerCon
merged.InitTimeout = &it
}
+ // Per-server concurrency overrides (spec 093, FR-020(c)): same tri-state
+ // patch semantics as InitTimeout — a non-nil pointer sets/replaces the
+ // override (including an explicit 0 = opt out), nil leaves the base value.
+ if patch.MaxConcurrentRequests != nil {
+ v := *patch.MaxConcurrentRequests
+ if diff != nil && (base.MaxConcurrentRequests == nil || *base.MaxConcurrentRequests != v) {
+ diff.Modified["max_concurrent_requests"] = FieldChange{Path: "max_concurrent_requests", From: base.MaxConcurrentRequests, To: patch.MaxConcurrentRequests}
+ }
+ merged.MaxConcurrentRequests = &v
+ }
+ if patch.QueueSize != nil {
+ v := *patch.QueueSize
+ if diff != nil && (base.QueueSize == nil || *base.QueueSize != v) {
+ diff.Modified["queue_size"] = FieldChange{Path: "queue_size", From: base.QueueSize, To: patch.QueueSize}
+ }
+ merged.QueueSize = &v
+ }
+ if patch.QueueTimeout != nil {
+ v := *patch.QueueTimeout
+ if diff != nil && (base.QueueTimeout == nil || *base.QueueTimeout != v) {
+ diff.Modified["queue_timeout"] = FieldChange{Path: "queue_timeout", From: base.QueueTimeout, To: patch.QueueTimeout}
+ }
+ merged.QueueTimeout = &v
+ }
+
// Always update the Updated timestamp
merged.Updated = time.Now()
@@ -615,6 +640,21 @@ func CopyServerConfig(src *ServerConfig) *ServerConfig {
dst.InitTimeout = &it
}
+ // Per-server concurrency overrides (spec 093): tri-state pointers copied by
+ // value so a copy-on-write snapshot never shares state with the live config.
+ if src.MaxConcurrentRequests != nil {
+ v := *src.MaxConcurrentRequests
+ dst.MaxConcurrentRequests = &v
+ }
+ if src.QueueSize != nil {
+ v := *src.QueueSize
+ dst.QueueSize = &v
+ }
+ if src.QueueTimeout != nil {
+ v := *src.QueueTimeout
+ dst.QueueTimeout = &v
+ }
+
// Copy the per-upstream auth-broker block by value (spec 074, server edition).
// In the personal edition AuthBrokerConfig is an empty stub struct, so this is
// a no-op there; copying by value keeps the pointer from being shared.
diff --git a/internal/contracts/activity.go b/internal/contracts/activity.go
index 655e8dfb5..563964457 100644
--- a/internal/contracts/activity.go
+++ b/internal/contracts/activity.go
@@ -40,7 +40,7 @@ type ActivityRecord struct {
Arguments map[string]interface{} `json:"arguments,omitempty" swaggertype:"object"` // Tool call arguments
Response string `json:"response,omitempty"` // Tool response (potentially truncated)
ResponseTruncated bool `json:"response_truncated,omitempty"` // True if response was truncated
- Status string `json:"status"` // Result status: "success", "error", "blocked"
+ Status string `json:"status"` // Result status: "success", "error", "blocked", "rejected"
ErrorMessage string `json:"error_message,omitempty"` // Error details if status is "error"
DurationMs int64 `json:"duration_ms,omitempty"` // Execution duration in milliseconds
Timestamp time.Time `json:"timestamp"` // When activity occurred
@@ -88,15 +88,20 @@ type ActivitySSEEvent struct {
// ActivitySummaryResponse is the response for GET /api/v1/activity/summary
type ActivitySummaryResponse struct {
- Period string `json:"period"` // Time period (1h, 24h, 7d, 30d)
- TotalCount int `json:"total_count"` // Total activity count
- SuccessCount int `json:"success_count"` // Count of successful activities
- ErrorCount int `json:"error_count"` // Count of error activities
- BlockedCount int `json:"blocked_count"` // Count of blocked activities
- TopServers []ActivityTopServer `json:"top_servers,omitempty"` // Top servers by activity count
- TopTools []ActivityTopTool `json:"top_tools,omitempty"` // Top tools by activity count
- StartTime string `json:"start_time"` // Start of the period (RFC3339)
- EndTime string `json:"end_time"` // End of the period (RFC3339)
+ Period string `json:"period"` // Time period (1h, 24h, 7d, 30d)
+ TotalCount int `json:"total_count"` // Total activity count
+ SuccessCount int `json:"success_count"` // Count of successful activities
+ ErrorCount int `json:"error_count"` // Count of error activities
+ BlockedCount int `json:"blocked_count"` // Count of blocked activities
+ // RejectedCount is the number of calls shed by a concurrency limiter before
+ // they reached an upstream (spec 093). Counted separately from errors: it is
+ // proxy backpressure, not an upstream fault, and it is the signal an
+ // operator right-sizes max_concurrent_requests against.
+ RejectedCount int `json:"rejected_count"`
+ TopServers []ActivityTopServer `json:"top_servers,omitempty"` // Top servers by activity count
+ TopTools []ActivityTopTool `json:"top_tools,omitempty"` // Top tools by activity count
+ StartTime string `json:"start_time"` // Start of the period (RFC3339)
+ EndTime string `json:"end_time"` // End of the period (RFC3339)
}
// ActivityTopServer represents a server's activity count in the summary
diff --git a/internal/contracts/converters.go b/internal/contracts/converters.go
index 05defa275..50fd53bd7 100644
--- a/internal/contracts/converters.go
+++ b/internal/contracts/converters.go
@@ -42,6 +42,11 @@ func ConvertServerConfig(cfg *config.ServerConfig, status string, connected bool
// MCP-3322: surface the per-server init_timeout override so callers can
// read back a configured handshake deadline.
InitTimeout: cfg.InitTimeout,
+ // Spec 093: surface the per-server concurrency overrides (tri-state) so a
+ // caller that PATCHed a limit can read it back.
+ MaxConcurrentRequests: cfg.MaxConcurrentRequests,
+ QueueSize: cfg.QueueSize,
+ QueueTimeout: cfg.QueueTimeout,
}
// Convert OAuth config if present
@@ -157,6 +162,25 @@ func ConvertUpstreamStatsToServerStats(stats map[string]interface{}) ServerStats
return serverStats
}
+// genericInt coerces a value from a generic map into an int. Generic server
+// maps reach this converter both straight from Go structs (int) and from JSON
+// round-trips (float64), so both encodings must be accepted. The bool result
+// distinguishes "key absent or not numeric" from a legitimate 0 — which matters
+// for the tri-state concurrency overrides, where 0 means "disabled" and absent
+// means "inherit" (spec 093 FR-020).
+func genericInt(v interface{}) (int, bool) {
+ switch n := v.(type) {
+ case int:
+ return n, true
+ case int64:
+ return int(n), true
+ case float64:
+ return int(n), true
+ default:
+ return 0, false
+ }
+}
+
// ConvertGenericServersToTyped converts []map[string]interface{} to []Server
func ConvertGenericServersToTyped(genericServers []map[string]interface{}) []Server {
servers := make([]Server, 0, len(genericServers))
@@ -205,6 +229,21 @@ func ConvertGenericServersToTyped(genericServers []map[string]interface{}) []Ser
server.InitTimeout = &v
}
}
+ // Spec 093: per-server concurrency overrides are tri-state, so only set
+ // the pointer when the key is actually present. Generic maps come from
+ // JSON, where every number decodes as float64.
+ if v, ok := genericInt(generic["max_concurrent_requests"]); ok {
+ server.MaxConcurrentRequests = &v
+ }
+ if v, ok := genericInt(generic["queue_size"]); ok {
+ server.QueueSize = &v
+ }
+ if queueTimeout, ok := generic["queue_timeout"].(string); ok && queueTimeout != "" {
+ if d, err := time.ParseDuration(queueTimeout); err == nil {
+ v := config.Duration(d)
+ server.QueueTimeout = &v
+ }
+ }
if connected, ok := generic["connected"].(bool); ok {
server.Connected = connected
}
diff --git a/internal/contracts/converters_test.go b/internal/contracts/converters_test.go
index 1a524e609..4f01142f1 100644
--- a/internal/contracts/converters_test.go
+++ b/internal/contracts/converters_test.go
@@ -323,3 +323,51 @@ func TestConvertGenericToolsToTyped_SchemaLegacyFallback(t *testing.T) {
require.Len(t, typed, 1)
assert.Equal(t, schema, typed[0].Schema, "legacy schema key must still be honored")
}
+
+// TestConvertServerConfig_ConcurrencyOverrides verifies the spec-093 per-server
+// concurrency overrides round-trip through both converters with tri-state
+// semantics intact: absent stays nil (inherit the default set) and an explicit
+// 0 is preserved as "disabled for this server", not collapsed into absent.
+func TestConvertServerConfig_ConcurrencyOverrides(t *testing.T) {
+ maxConc, queueSize := 5, 10
+ qt := config.Duration(45 * time.Second)
+ cfg := &config.ServerConfig{
+ Name: "db", Enabled: true,
+ MaxConcurrentRequests: &maxConc,
+ QueueSize: &queueSize,
+ QueueTimeout: &qt,
+ }
+ server := ConvertServerConfig(cfg, "ready", true, 0, false)
+ require.NotNil(t, server.MaxConcurrentRequests)
+ assert.Equal(t, 5, *server.MaxConcurrentRequests)
+ require.NotNil(t, server.QueueSize)
+ assert.Equal(t, 10, *server.QueueSize)
+ require.NotNil(t, server.QueueTimeout)
+ assert.Equal(t, 45*time.Second, server.QueueTimeout.Duration())
+
+ unset := ConvertServerConfig(&config.ServerConfig{Name: "unset", Enabled: true}, "ready", true, 0, false)
+ assert.Nil(t, unset.MaxConcurrentRequests, "absent must stay nil so the server inherits the default set")
+ assert.Nil(t, unset.QueueSize)
+ assert.Nil(t, unset.QueueTimeout)
+
+ optOut := 0
+ disabled := ConvertServerConfig(&config.ServerConfig{Name: "off", Enabled: true, MaxConcurrentRequests: &optOut}, "ready", true, 0, false)
+ require.NotNil(t, disabled.MaxConcurrentRequests, "an explicit 0 opt-out must survive the conversion")
+ assert.Equal(t, 0, *disabled.MaxConcurrentRequests)
+
+ generic := ConvertGenericServersToTyped([]map[string]interface{}{
+ // JSON round-trip shape: numbers arrive as float64.
+ {"id": "db", "name": "db", "enabled": true, "max_concurrent_requests": float64(5), "queue_size": float64(0), "queue_timeout": "45s"},
+ {"id": "unset", "name": "unset", "enabled": true},
+ })
+ require.Len(t, generic, 2)
+ require.NotNil(t, generic[0].MaxConcurrentRequests)
+ assert.Equal(t, 5, *generic[0].MaxConcurrentRequests)
+ require.NotNil(t, generic[0].QueueSize, "queue_size 0 is a real value, not an absent key")
+ assert.Equal(t, 0, *generic[0].QueueSize)
+ require.NotNil(t, generic[0].QueueTimeout)
+ assert.Equal(t, 45*time.Second, generic[0].QueueTimeout.Duration())
+ assert.Nil(t, generic[1].MaxConcurrentRequests)
+ assert.Nil(t, generic[1].QueueSize)
+ assert.Nil(t, generic[1].QueueTimeout)
+}
diff --git a/internal/contracts/types.go b/internal/contracts/types.go
index ae4c36354..a0191f07e 100644
--- a/internal/contracts/types.go
+++ b/internal/contracts/types.go
@@ -89,6 +89,16 @@ type Server struct {
// and omitted when empty — clients that pre-date this treat them as absent.
SourceRegistryID string `json:"source_registry_id,omitempty"`
SourceRegistryProvenance string `json:"source_registry_provenance,omitempty"`
+ // Spec 093 (GH #955) — per-server concurrency overrides, scope (c) of
+ // FR-020. Each setting is tri-state: nil (omitted) means "inherit
+ // server_concurrency_defaults", 0 disables that setting for this server,
+ // positive overrides it. Surfaced on the GET path so a caller can read back
+ // what it set; PATCH/POST accept them via AddServerRequest. The effective
+ // concurrency for a server is additionally bounded by the global aggregate
+ // limiter, which is NOT an inheritance source for these fields.
+ MaxConcurrentRequests *int `json:"max_concurrent_requests,omitempty"`
+ QueueSize *int `json:"queue_size,omitempty"`
+ QueueTimeout *config.Duration `json:"queue_timeout,omitempty" swaggertype:"string"`
}
// Diagnostic is the REST-API representation of a classified server failure.
@@ -383,6 +393,7 @@ type UsageToolStat struct {
Errors int64 `json:"errors"`
ErrorRate float64 `json:"error_rate"`
Blocked int64 `json:"blocked"`
+ Rejected int64 `json:"rejected"` // spec 093: shed by a concurrency limit; never executed, so excluded from calls/latency
TotalRespBytes int64 `json:"total_resp_bytes"`
AvgRespBytes *int64 `json:"avg_resp_bytes"` // null when sized_calls == 0 (only legacy 0-byte calls)
TotalReqBytes int64 `json:"total_req_bytes"`
diff --git a/internal/httpapi/activity.go b/internal/httpapi/activity.go
index dff57a53c..e04c5c39b 100644
--- a/internal/httpapi/activity.go
+++ b/internal/httpapi/activity.go
@@ -88,8 +88,9 @@ func parseActivityFilters(r *http.Request) storage.ActivityFilter {
filter.RequestID = requestID
}
- // Include call_tool_* internal tool calls (default: exclude successful ones)
- // Set include_call_tool=true to show all internal tool calls including successful call_tool_*
+ // Include call_tool_* internal tool calls (default: exclude the ones a
+ // tool_call record already covers — successful and concurrency-rejected).
+ // Set include_call_tool=true to show every internal tool call.
if q.Get("include_call_tool") == "true" {
filter.ExcludeCallToolSuccess = false
}
@@ -131,7 +132,7 @@ func parseActivityFilters(r *http.Request) storage.ActivityFilter {
// @Param tool query string false "Filter by tool name"
// @Param session_id query string false "Filter by MCP transport session ID"
// @Param work_session_id query string false "Filter by work session (one client, one project, across reconnects)"
-// @Param status query string false "Filter by status" Enums(success, error, blocked)
+// @Param status query string false "Filter by status" Enums(success, error, blocked, rejected)
// @Param intent_type query string false "Filter by intent operation type (Spec 018)" Enums(read, write, destructive)
// @Param request_id query string false "Filter by HTTP request ID for log correlation (Spec 021)"
// @Param include_call_tool query bool false "Include successful call_tool_* internal tool calls (default: false, excluded to avoid duplicates)"
@@ -634,19 +635,24 @@ func (s *Server) handleActivitySummary(w http.ResponseWriter, r *http.Request) {
}
// Calculate summary statistics
- var totalCount, successCount, errorCount, blockedCount int
+ var totalCount, successCount, errorCount, blockedCount, rejectedCount int
serverCounts := make(map[string]int)
toolCounts := make(map[string]int)
for _, a := range activities {
totalCount++
switch a.Status {
- case "success":
+ case storage.ActivityStatusSuccess:
successCount++
- case "error":
+ case storage.ActivityStatusError:
errorCount++
- case "blocked":
+ case storage.ActivityStatusBlocked:
blockedCount++
+ case storage.ActivityStatusRejected:
+ // Spec 093: shed by a concurrency limit — proxy backpressure, kept
+ // out of the error bucket so a saturated limiter does not read as an
+ // upstream outage.
+ rejectedCount++
}
// Count by server
@@ -668,15 +674,16 @@ func (s *Server) handleActivitySummary(w http.ResponseWriter, r *http.Request) {
topTools := buildTopTools(toolCounts, 5)
response := contracts.ActivitySummaryResponse{
- Period: period,
- TotalCount: totalCount,
- SuccessCount: successCount,
- ErrorCount: errorCount,
- BlockedCount: blockedCount,
- TopServers: topServers,
- TopTools: topTools,
- StartTime: startTime.Format(time.RFC3339),
- EndTime: endTime.Format(time.RFC3339),
+ Period: period,
+ TotalCount: totalCount,
+ SuccessCount: successCount,
+ ErrorCount: errorCount,
+ BlockedCount: blockedCount,
+ RejectedCount: rejectedCount,
+ TopServers: topServers,
+ TopTools: topTools,
+ StartTime: startTime.Format(time.RFC3339),
+ EndTime: endTime.Format(time.RFC3339),
}
s.writeSuccess(w, response)
@@ -775,7 +782,7 @@ type usageParams struct {
window string // "24h" | "7d" | "all"
server string
tool string
- status string // "" | "success" | "error" | "blocked"
+ status string // "" | "success" | "error" | "blocked" | "rejected"
top int
sort string // "calls" | "resp_bytes" | "error_rate" | "p95"
}
@@ -831,9 +838,12 @@ func parseUsageParams(r *http.Request) (usageParams, error) {
if p.status != "" {
switch p.status {
- case "success", "error", "blocked":
+ // Spec 093: "rejected" (shed by a concurrency limit) is part of the
+ // activity status vocabulary, so the usage filter must accept it —
+ // usageMatchesStatus already knows how to answer it.
+ case "success", "error", "blocked", "rejected":
default:
- return p, fmt.Errorf("invalid status %q (expected success, error, or blocked)", p.status)
+ return p, fmt.Errorf("invalid status %q (expected success, error, blocked, or rejected)", p.status)
}
}
@@ -857,7 +867,7 @@ func parseUsageParams(r *http.Request) (usageParams, error) {
// @Param window query string false "Time window for timeline + tool-list membership" Enums(24h, 7d, all)
// @Param server query string false "Filter to one server"
// @Param tool query string false "Filter to one tool"
-// @Param status query string false "Filter to tools with activity of this status" Enums(success, error, blocked)
+// @Param status query string false "Filter to tools with activity of this status" Enums(success, error, blocked, rejected)
// @Param top query int false "Top-N tools by sort key; remainder folded into 'other' (default 20)"
// @Param sort query string false "Ranking key for the per-tool list" Enums(calls, resp_bytes, error_rate, p95)
// @Success 200 {object} contracts.APIResponse{data=contracts.UsageAggregateResponse}
@@ -990,6 +1000,8 @@ func usageMatchesStatus(tu *internalRuntime.ToolUsage, status string) bool {
return tu.Errors > 0
case "blocked":
return tu.Blocked > 0
+ case "rejected":
+ return tu.Rejected > 0
case "success":
return tu.Calls-tu.Errors > 0
default:
@@ -1006,6 +1018,7 @@ func usageToolStat(tu *internalRuntime.ToolUsage) contracts.UsageToolStat {
Errors: tu.Errors,
ErrorRate: tu.ErrorRate(),
Blocked: tu.Blocked,
+ Rejected: tu.Rejected,
TotalRespBytes: tu.RespBytesSum,
TotalReqBytes: tu.ReqBytesSum,
SizedCalls: tu.SizedRespCalls,
diff --git a/internal/httpapi/activity_usage_test.go b/internal/httpapi/activity_usage_test.go
index dbdd20fbb..fe1f81f92 100644
--- a/internal/httpapi/activity_usage_test.go
+++ b/internal/httpapi/activity_usage_test.go
@@ -240,6 +240,28 @@ func TestActivityUsage_Filters(t *testing.T) {
})
}
+// TestActivityUsage_RejectedStatusFilter is the spec-093 (#955) regression: the
+// aggregate learned to count "rejected" (shed by a concurrency limit) and
+// usageMatchesStatus learned to answer it, but the query validator still had a
+// three-value whitelist — so ?status=rejected 400'd and the matcher branch was
+// unreachable.
+func TestActivityUsage_RejectedStatusFilter(t *testing.T) {
+ now := time.Now().UTC()
+ snap := buildUsageSnapshot(
+ toolCall("github", "ok", "success", 10, 100, 5, now),
+ toolCall("fragile-db", "query", "success", 10, 100, 5, now),
+ toolCall("fragile-db", "query", storage.ActivityStatusRejected, 0, 0, 0, now),
+ )
+ ctrl := &mockUsageController{apiKey: "test-key", snap: snap}
+ srv := NewServer(ctrl, zap.NewNop().Sugar(), nil)
+
+ w, data := doUsageRequest(t, srv, "?status=rejected")
+ require.Equal(t, http.StatusOK, w.Code, "rejected must be an accepted status filter")
+ require.Len(t, data.Tools, 1)
+ assert.Equal(t, "query", data.Tools[0].Tool)
+ assert.Equal(t, "fragile-db", data.Tools[0].Server)
+}
+
func TestActivityUsage_EmptyState(t *testing.T) {
// nil snapshot (service not ready) and empty snapshot both yield a clean 200.
for name, snap := range map[string]*internalRuntime.UsageAggregate{
diff --git a/internal/httpapi/concurrency_shed.go b/internal/httpapi/concurrency_shed.go
new file mode 100644
index 000000000..f8095349a
--- /dev/null
+++ b/internal/httpapi/concurrency_shed.go
@@ -0,0 +1,43 @@
+package httpapi
+
+import (
+ "math"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/reqcontext"
+)
+
+// toolCallRequestSource classifies who made a REST tool call, so the activity
+// record — and the "rejected" record a shed produces (spec 093 FR-012) — names
+// the real origin. POST /api/v1/tools/call is shared by the CLI, the Web UI and
+// the tray; the surface header those clients already send ("cli/",
+// "webui/web", "tray/") is what tells them apart. Anything that does
+// not identify itself as the CLI is plain REST.
+func toolCallRequestSource(r *http.Request) reqcontext.RequestSource {
+ if strings.HasPrefix(strings.ToLower(r.Header.Get(XMCPProxyClientHeader)), "cli/") {
+ return reqcontext.SourceCLI
+ }
+ return reqcontext.SourceRESTAPI
+}
+
+// defaultRetryAfterSeconds is the hint used when the shedding scope reported no
+// queue_timeout (a `queue_size: 0` scope sheds instantly and has no wait
+// budget). Something small and concrete beats omitting the header: a client
+// with no hint typically retries immediately and re-sheds.
+const defaultRetryAfterSeconds = 1
+
+// retryAfterSeconds converts a scope's effective queue_timeout into the
+// Retry-After header value (RFC 9110 delta-seconds, spec 093 FR-011). Sub-second
+// budgets round UP to 1 so the header never says "retry now".
+func retryAfterSeconds(d time.Duration) int {
+ if d <= 0 {
+ return defaultRetryAfterSeconds
+ }
+ secs := int(math.Ceil(d.Seconds()))
+ if secs < 1 {
+ return 1
+ }
+ return secs
+}
diff --git a/internal/httpapi/concurrency_shed_test.go b/internal/httpapi/concurrency_shed_test.go
new file mode 100644
index 000000000..a4bf369a0
--- /dev/null
+++ b/internal/httpapi/concurrency_shed_test.go
@@ -0,0 +1,210 @@
+package httpapi
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.uber.org/zap"
+
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/reqcontext"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/upstream/limiter"
+)
+
+// shedController answers every tool call with the typed limiter rejection,
+// wrapped the way the real dispatch chain wraps it (Server.CallTool adds
+// "tool call failed: %w") so the handler's errors.As has to look through a
+// wrapper, as it does in production.
+type shedController struct {
+ baseController
+ apiKey string
+ err error
+}
+
+func (m *shedController) GetCurrentConfig() any {
+ return &config.Config{APIKey: m.apiKey}
+}
+
+func (m *shedController) CallTool(_ context.Context, _ string, _ map[string]interface{}) (interface{}, error) {
+ return nil, m.err
+}
+
+func postToolCall(t *testing.T, srv *Server, apiKey string) *httptest.ResponseRecorder {
+ t.Helper()
+ body := strings.NewReader(`{"tool_name":"call_tool_read","arguments":{"name":"db:query"}}`)
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/tools/call", body)
+ req.Header.Set("X-API-Key", apiKey)
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ srv.ServeHTTP(w, req)
+ return w
+}
+
+// TestHandleCallTool_ShedReturns429WithRetryAfter is the FR-011 contract: the
+// REST surface answers a concurrency shed with 429 and a Retry-After hint
+// derived from the shedding scope's effective queue_timeout — not the blanket
+// 500 a flattened string error would have produced.
+func TestHandleCallTool_ShedReturns429WithRetryAfter(t *testing.T) {
+ t.Setenv("CI", "")
+ apiKey := "test-shed-api-key"
+
+ cases := []struct {
+ name string
+ limitErr *limiter.LimitError
+ wantRetry string
+ wantInBody string
+ notInBody string
+ wantCodeMsg string
+ }{
+ {
+ name: "server scope queue full",
+ limitErr: &limiter.LimitError{
+ Scope: limiter.ScopeServer, Reason: limiter.ReasonQueueFull,
+ Server: "analytics-db", Limit: 2, RetryAfter: 30 * time.Second,
+ },
+ wantRetry: "30",
+ wantInBody: "analytics-db",
+ },
+ {
+ name: "global scope queue timeout rounds up",
+ limitErr: &limiter.LimitError{
+ Scope: limiter.ScopeGlobal, Reason: limiter.ReasonQueueTimeout,
+ Limit: 20, RetryAfter: 1500 * time.Millisecond,
+ },
+ wantRetry: "2",
+ wantInBody: "proxy-wide",
+ notInBody: "analytics-db",
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ ctrl := &shedController{
+ apiKey: apiKey,
+ err: fmt.Errorf("tool call failed: %w", tc.limitErr),
+ }
+ srv := NewServer(ctrl, zap.NewNop().Sugar(), nil)
+
+ w := postToolCall(t, srv, apiKey)
+
+ require.Equal(t, http.StatusTooManyRequests, w.Code)
+ assert.Equal(t, tc.wantRetry, w.Header().Get("Retry-After"))
+
+ var body map[string]interface{}
+ require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
+ msg, _ := body["error"].(string)
+ assert.Contains(t, msg, tc.wantInBody)
+ assert.Contains(t, msg, limiter.RetryAdvice)
+ if tc.notInBody != "" {
+ assert.NotContains(t, msg, tc.notInBody)
+ }
+ })
+ }
+}
+
+// TestHandleCallTool_ServerUnavailableIsNot429 keeps FR-009 separate from
+// FR-011: a server that went away mid-queue is not backpressure.
+func TestHandleCallTool_ServerUnavailableIsNot429(t *testing.T) {
+ t.Setenv("CI", "")
+ apiKey := "test-shed-api-key"
+
+ ctrl := &shedController{
+ apiKey: apiKey,
+ err: fmt.Errorf("tool call failed: %w", &limiter.LimitError{
+ Scope: limiter.ScopeServer, Reason: limiter.ReasonServerUnavailable, Server: "db",
+ }),
+ }
+ srv := NewServer(ctrl, zap.NewNop().Sugar(), nil)
+
+ w := postToolCall(t, srv, apiKey)
+
+ assert.Equal(t, http.StatusInternalServerError, w.Code)
+ assert.Empty(t, w.Header().Get("Retry-After"))
+}
+
+// TestHandleReplayToolCall_ShedReturns429 extends FR-011 to the replay endpoint.
+// Replay used to flatten the rejection into the new record's Error field and
+// return no error at all, so a shed answered 200 with success:true — a client
+// could not tell a replay that never ran from one that did.
+func TestHandleReplayToolCall_ShedReturns429(t *testing.T) {
+ t.Setenv("CI", "")
+ apiKey := "test-shed-api-key"
+
+ ctrl := &replayShedController{shedController{
+ apiKey: apiKey,
+ err: fmt.Errorf("tool call failed: %w", &limiter.LimitError{
+ Scope: limiter.ScopeServer, Reason: limiter.ReasonQueueTimeout,
+ Server: "analytics-db", Limit: 2, RetryAfter: 30 * time.Second,
+ }),
+ }}
+ srv := NewServer(ctrl, zap.NewNop().Sugar(), nil)
+
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/tool-calls/call-1/replay", strings.NewReader(`{}`))
+ req.Header.Set("X-API-Key", apiKey)
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ srv.ServeHTTP(w, req)
+
+ require.Equal(t, http.StatusTooManyRequests, w.Code)
+ assert.Equal(t, "30", w.Header().Get("Retry-After"))
+
+ var body map[string]interface{}
+ require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
+ assert.Equal(t, false, body["success"])
+ msg, _ := body["error"].(string)
+ assert.Contains(t, msg, "analytics-db")
+ assert.Contains(t, msg, limiter.RetryAdvice)
+}
+
+type replayShedController struct {
+ shedController
+}
+
+func (m *replayShedController) ReplayToolCall(_ context.Context, _ string, _ map[string]interface{}) (*contracts.ToolCallRecord, error) {
+ return nil, m.err
+}
+
+// TestRetryAfterSeconds covers the delta-seconds conversion, including the
+// "never say retry now" rounding rule.
+func TestRetryAfterSeconds(t *testing.T) {
+ assert.Equal(t, 1, retryAfterSeconds(0))
+ assert.Equal(t, 1, retryAfterSeconds(-5*time.Second))
+ assert.Equal(t, 1, retryAfterSeconds(10*time.Millisecond))
+ assert.Equal(t, 2, retryAfterSeconds(1100*time.Millisecond))
+ assert.Equal(t, 30, retryAfterSeconds(30*time.Second))
+}
+
+// TestToolCallRequestSource is the P3 origin-attribution fix. POST
+// /api/v1/tools/call is shared by the CLI, the Web UI and the tray, and it used
+// to stamp every one of them as CLI — overwriting the REST source the
+// middleware had set, so a Web-UI tool call (and any shed of one) was logged
+// against the wrong origin.
+func TestToolCallRequestSource(t *testing.T) {
+ cases := []struct {
+ header string
+ want reqcontext.RequestSource
+ }{
+ {"cli/v0.52.0", reqcontext.SourceCLI},
+ {"CLI/dev", reqcontext.SourceCLI},
+ {"webui/web", reqcontext.SourceRESTAPI},
+ {"tray/v0.52.0", reqcontext.SourceRESTAPI},
+ {"", reqcontext.SourceRESTAPI},
+ {"clipper/1.0", reqcontext.SourceRESTAPI},
+ }
+ for _, tc := range cases {
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/tools/call", strings.NewReader("{}"))
+ if tc.header != "" {
+ req.Header.Set(XMCPProxyClientHeader, tc.header)
+ }
+ assert.Equal(t, tc.want, toolCallRequestSource(req), "header %q", tc.header)
+ }
+}
diff --git a/internal/httpapi/contracts_test.go b/internal/httpapi/contracts_test.go
index 1451435e6..7f238c0e0 100644
--- a/internal/httpapi/contracts_test.go
+++ b/internal/httpapi/contracts_test.go
@@ -243,7 +243,7 @@ func (m *MockServerController) GetToolCallByID(_ string) (*contracts.ToolCallRec
func (m *MockServerController) GetServerToolCalls(_ string, _ int) ([]*contracts.ToolCallRecord, error) {
return []*contracts.ToolCallRecord{}, nil
}
-func (m *MockServerController) ReplayToolCall(_ string, _ map[string]interface{}) (*contracts.ToolCallRecord, error) {
+func (m *MockServerController) ReplayToolCall(_ context.Context, _ string, _ map[string]interface{}) (*contracts.ToolCallRecord, error) {
return &contracts.ToolCallRecord{
ID: "replayed-call-123",
ServerName: "test-server",
diff --git a/internal/httpapi/patch_server_test.go b/internal/httpapi/patch_server_test.go
index 11ea10500..e197d6e4c 100644
--- a/internal/httpapi/patch_server_test.go
+++ b/internal/httpapi/patch_server_test.go
@@ -844,3 +844,119 @@ func TestHandleConvertConfigToSecret_ServerNotFound(t *testing.T) {
require.Equal(t, http.StatusNotFound, w.Code, "body=%s", w.Body.String())
require.Contains(t, w.Body.String(), `missing`)
}
+
+// TestHandlePatchServer_ConcurrencyOverrides verifies the spec-093 per-server
+// concurrency limits are settable over REST (FR-020 scope (c) is documented as
+// file/API-configured) with tri-state nil-preserve semantics: an explicit value
+// (including 0, the documented per-server opt-out) is applied, and an omitted
+// field never wipes a configured limit.
+func TestHandlePatchServer_ConcurrencyOverrides(t *testing.T) {
+ logger := zap.NewNop().Sugar()
+
+ intPtr := func(v int) *int { return &v }
+ durPtr := func(d time.Duration) *config.Duration { v := config.Duration(d); return &v }
+
+ newServer := func() *config.ServerConfig {
+ return &config.ServerConfig{Name: "db", Protocol: "stdio", Command: "docker", Enabled: true}
+ }
+
+ patch := func(t *testing.T, existing *config.ServerConfig, body string) *config.ServerConfig {
+ t.Helper()
+ mockCtrl := &mockPatchServerController{apiKey: "test-key", existingServer: existing}
+ srv := NewServer(mockCtrl, logger, nil)
+ req := httptest.NewRequest(http.MethodPatch, "/api/v1/servers/db", bytes.NewReader([]byte(body)))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("X-API-Key", "test-key")
+ w := httptest.NewRecorder()
+ srv.ServeHTTP(w, req)
+ require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
+ require.NotNil(t, mockCtrl.capturedUpdates, "UpdateServer should have been called")
+ return mockCtrl.capturedUpdates
+ }
+
+ t.Run("explicit values set the pointers", func(t *testing.T) {
+ updates := patch(t, newServer(), `{"max_concurrent_requests":5,"queue_size":10,"queue_timeout":"45s"}`)
+ require.NotNil(t, updates.MaxConcurrentRequests)
+ assert.Equal(t, 5, *updates.MaxConcurrentRequests)
+ require.NotNil(t, updates.QueueSize)
+ assert.Equal(t, 10, *updates.QueueSize)
+ require.NotNil(t, updates.QueueTimeout)
+ assert.Equal(t, 45*time.Second, updates.QueueTimeout.Duration())
+ })
+
+ t.Run("explicit zero is a real opt-out, not an omission", func(t *testing.T) {
+ existing := newServer()
+ existing.MaxConcurrentRequests = intPtr(5)
+ updates := patch(t, existing, `{"max_concurrent_requests":0}`)
+ require.NotNil(t, updates.MaxConcurrentRequests)
+ assert.Equal(t, 0, *updates.MaxConcurrentRequests)
+ })
+
+ t.Run("omitting preserves a prior value", func(t *testing.T) {
+ existing := newServer()
+ existing.MaxConcurrentRequests = intPtr(5)
+ existing.QueueSize = intPtr(10)
+ existing.QueueTimeout = durPtr(45 * time.Second)
+ updates := patch(t, existing, `{"args":["new-arg"]}`)
+ require.NotNil(t, updates.MaxConcurrentRequests)
+ assert.Equal(t, 5, *updates.MaxConcurrentRequests)
+ require.NotNil(t, updates.QueueSize)
+ assert.Equal(t, 10, *updates.QueueSize)
+ require.NotNil(t, updates.QueueTimeout)
+ assert.Equal(t, 45*time.Second, updates.QueueTimeout.Duration())
+ })
+
+ t.Run("omitting preserves nil existing", func(t *testing.T) {
+ updates := patch(t, newServer(), `{"args":["new-arg"]}`)
+ assert.Nil(t, updates.MaxConcurrentRequests)
+ assert.Nil(t, updates.QueueSize)
+ assert.Nil(t, updates.QueueTimeout)
+ })
+}
+
+// TestHandleGetServers_ExposesConcurrencyOverrides verifies the GET payload
+// surfaces the per-server limits so a caller can read back what it PATCHed.
+func TestHandleGetServers_ExposesConcurrencyOverrides(t *testing.T) {
+ logger := zap.NewNop().Sugar()
+ mockCtrl := &mockPatchServerController{
+ apiKey: "test-key",
+ allServers: []map[string]interface{}{
+ {
+ "id": "db",
+ "name": "db",
+ "enabled": true,
+ "quarantined": false,
+ "max_concurrent_requests": 5,
+ "queue_size": 10,
+ "queue_timeout": "45s",
+ },
+ },
+ }
+ srv := NewServer(mockCtrl, logger, nil)
+
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/servers", http.NoBody)
+ req.Header.Set("X-API-Key", "test-key")
+ w := httptest.NewRecorder()
+ srv.ServeHTTP(w, req)
+
+ require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
+
+ var resp struct {
+ Data struct {
+ Servers []struct {
+ Name string `json:"name"`
+ MaxConcurrentRequests *int `json:"max_concurrent_requests"`
+ QueueSize *int `json:"queue_size"`
+ QueueTimeout string `json:"queue_timeout"`
+ } `json:"servers"`
+ } `json:"data"`
+ }
+ require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
+ require.Len(t, resp.Data.Servers, 1)
+ require.NotNil(t, resp.Data.Servers[0].MaxConcurrentRequests, "max_concurrent_requests must appear in the GET payload")
+ assert.Equal(t, 5, *resp.Data.Servers[0].MaxConcurrentRequests)
+ require.NotNil(t, resp.Data.Servers[0].QueueSize)
+ assert.Equal(t, 10, *resp.Data.Servers[0].QueueSize)
+ assert.Equal(t, "45s", resp.Data.Servers[0].QueueTimeout,
+ "queue_timeout must appear in the GET payload as a duration string")
+}
diff --git a/internal/httpapi/security_test.go b/internal/httpapi/security_test.go
index 887bf48bc..ea4452787 100644
--- a/internal/httpapi/security_test.go
+++ b/internal/httpapi/security_test.go
@@ -277,7 +277,7 @@ func (m *baseController) GetToolCallByID(id string) (*contracts.ToolCallRecord,
func (m *baseController) GetServerToolCalls(serverName string, limit int) ([]*contracts.ToolCallRecord, error) {
return nil, nil
}
-func (m *baseController) ReplayToolCall(id string, args map[string]interface{}) (*contracts.ToolCallRecord, error) {
+func (m *baseController) ReplayToolCall(_ context.Context, id string, args map[string]interface{}) (*contracts.ToolCallRecord, error) {
return nil, nil
}
func (m *baseController) ValidateConfig(cfg *config.Config) ([]config.ValidationError, error) {
diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go
index 0121aa337..74018700d 100644
--- a/internal/httpapi/server.go
+++ b/internal/httpapi/server.go
@@ -38,6 +38,7 @@ import (
"github.com/smart-mcp-proxy/mcpproxy-go/internal/transport"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/updatecheck"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/upstream/core"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/upstream/limiter"
)
const (
@@ -104,7 +105,7 @@ type ServerController interface {
GetToolCalls(limit, offset int) ([]*contracts.ToolCallRecord, int, error)
GetToolCallByID(id string) (*contracts.ToolCallRecord, error)
GetServerToolCalls(serverName string, limit int) ([]*contracts.ToolCallRecord, error)
- ReplayToolCall(id string, arguments map[string]interface{}) (*contracts.ToolCallRecord, error)
+ ReplayToolCall(ctx context.Context, id string, arguments map[string]interface{}) (*contracts.ToolCallRecord, error)
GetToolCallsBySession(sessionID string, limit, offset int) ([]*contracts.ToolCallRecord, int, error)
// Session management. status filters on session status ("active" /
@@ -1508,6 +1509,16 @@ type AddServerRequest struct {
// pointer means "leave unchanged" on PATCH; a present value is applied.
// Mirrors config.ServerConfig.InitTimeout's *Duration tri-state.
InitTimeout *config.Duration `json:"init_timeout,omitempty" swaggertype:"string"`
+ // MaxConcurrentRequests / QueueSize / QueueTimeout are the per-server
+ // concurrency overrides (spec 093 / GH #955, FR-020 scope (c)). Each is
+ // tri-state: a nil pointer means "leave unchanged" on PATCH and "inherit
+ // server_concurrency_defaults" on create; an explicit 0 disables that
+ // setting for this server; a positive value overrides it. Do NOT collapse
+ // them to plain values — an omitted field would then silently reset a
+ // configured limit.
+ MaxConcurrentRequests *int `json:"max_concurrent_requests,omitempty"`
+ QueueSize *int `json:"queue_size,omitempty"`
+ QueueTimeout *config.Duration `json:"queue_timeout,omitempty" swaggertype:"string"`
// Isolation carries per-server Docker isolation overrides (image,
// network_mode, extra_args, working_dir, enabled). A nil pointer
// means "do not touch isolation config"; an empty-but-present
@@ -1680,6 +1691,18 @@ func (s *Server) handleAddServer(w http.ResponseWriter, r *http.Request) {
if req.InitTimeout != nil {
serverConfig.InitTimeout = req.InitTimeout
}
+ // Spec 093: carry the per-server concurrency overrides through on create.
+ // Tri-state pointers — only set when the caller actually provided them, so
+ // an omitted field still inherits server_concurrency_defaults.
+ if req.MaxConcurrentRequests != nil {
+ serverConfig.MaxConcurrentRequests = req.MaxConcurrentRequests
+ }
+ if req.QueueSize != nil {
+ serverConfig.QueueSize = req.QueueSize
+ }
+ if req.QueueTimeout != nil {
+ serverConfig.QueueTimeout = req.QueueTimeout
+ }
// Carry the per-server Docker isolation override through on create. The
// AddServerRequest has always declared (and documented) an Isolation
// field, but only the PATCH/update path mapped it — on create it was
@@ -1939,6 +1962,27 @@ func (s *Server) handlePatchServer(w http.ResponseWriter, r *http.Request) {
} else if existingSrv != nil {
updates.InitTimeout = existingSrv.InitTimeout
}
+ // Spec 093: the per-server concurrency overrides are tri-state pointers —
+ // preserve the existing values when the request omits them so an unrelated
+ // PATCH cannot wipe a configured limit.
+ if req.MaxConcurrentRequests != nil {
+ updates.MaxConcurrentRequests = req.MaxConcurrentRequests
+ hasUpdates = true
+ } else if existingSrv != nil {
+ updates.MaxConcurrentRequests = existingSrv.MaxConcurrentRequests
+ }
+ if req.QueueSize != nil {
+ updates.QueueSize = req.QueueSize
+ hasUpdates = true
+ } else if existingSrv != nil {
+ updates.QueueSize = existingSrv.QueueSize
+ }
+ if req.QueueTimeout != nil {
+ updates.QueueTimeout = req.QueueTimeout
+ hasUpdates = true
+ } else if existingSrv != nil {
+ updates.QueueTimeout = existingSrv.QueueTimeout
+ }
if req.Isolation != nil {
updates.Isolation = req.Isolation.toConfig()
hasUpdates = true
@@ -4010,6 +4054,7 @@ func convertToolCallPointers(pointers []*contracts.ToolCallRecord) []contracts.T
// @Failure 400 {object} contracts.ErrorResponse "Tool call ID required or invalid JSON payload"
// @Failure 401 {object} contracts.ErrorResponse "Unauthorized - missing or invalid API key"
// @Failure 405 {object} contracts.ErrorResponse "Method not allowed"
+// @Failure 429 {object} contracts.ErrorResponse "Shed by a concurrency limit (Retry-After header carries the wait hint)"
// @Failure 500 {object} contracts.ErrorResponse "Failed to replay tool call"
// @Security ApiKeyAuth
// @Security ApiKeyQuery
@@ -4033,9 +4078,26 @@ func (s *Server) handleReplayToolCall(w http.ResponseWriter, r *http.Request) {
return
}
- // Replay the tool call with modified arguments
- newToolCall, err := s.controller.ReplayToolCall(id, request.Arguments)
+ // Replay the tool call with modified arguments. The request context travels
+ // with it so a client that disconnects while the replay waits for a
+ // concurrency slot releases that slot immediately (spec 093 FR-005).
+ newToolCall, err := s.controller.ReplayToolCall(r.Context(), id, request.Arguments)
if err != nil {
+ // Spec 093 FR-011: a replay shed by a concurrency limit is backpressure,
+ // answered like any other shed tool call — 429 + Retry-After, not a 500
+ // and certainly not the 200 success:true it used to produce when the
+ // rejection was flattened into the record's error field.
+ var limitErr *limiter.LimitError
+ if errors.As(err, &limitErr) &&
+ (limitErr.Reason == limiter.ReasonQueueFull || limitErr.Reason == limiter.ReasonQueueTimeout) {
+ w.Header().Set("Retry-After", strconv.Itoa(retryAfterSeconds(limitErr.RetryAfter)))
+ s.logger.Warnw("Tool call replay shed by concurrency limiter",
+ "id", id,
+ "scope", string(limitErr.Scope),
+ "reason", string(limitErr.Reason))
+ s.writeError(w, r, http.StatusTooManyRequests, limitErr.UserMessage())
+ return
+ }
s.logger.Error("Failed to replay tool call", "id", id, "error", err)
s.writeError(w, r, http.StatusInternalServerError, fmt.Sprintf("Failed to replay tool call: %v", err))
return
@@ -4370,6 +4432,7 @@ func deepMergeJSON(base, patch map[string]interface{}) {
// @Param request body object{tool_name=string,arguments=object} true "Tool call request with tool name and arguments"
// @Success 200 {object} contracts.SuccessResponse "Tool call result"
// @Failure 400 {object} contracts.ErrorResponse "Bad request (invalid payload or missing tool name)"
+// @Failure 429 {object} contracts.ErrorResponse "Shed by a concurrency limit (Retry-After header carries the wait hint)"
// @Failure 500 {object} contracts.ErrorResponse "Internal server error or tool execution failure"
// @Router /api/v1/tools/call [post]
func (s *Server) handleCallTool(w http.ResponseWriter, r *http.Request) {
@@ -4393,13 +4456,30 @@ func (s *Server) handleCallTool(w http.ResponseWriter, r *http.Request) {
return
}
- // Set request source to CLI for REST API tool calls (typically from CLI)
- // This allows activity logging to distinguish between MCP protocol and CLI calls
- ctx := reqcontext.WithRequestSource(r.Context(), reqcontext.SourceCLI)
+ // Attribute the call to the surface that actually made it. This endpoint is
+ // shared by the CLI, the Web UI and the tray, so hard-coding CLI here
+ // overwrote the REST source the middleware had already established and
+ // logged every Web-UI tool call — and every shed of one — as if it came from
+ // the CLI. The surface header the clients already send is the discriminator.
+ ctx := reqcontext.WithRequestSource(r.Context(), toolCallRequestSource(r))
// Call tool via controller
result, err := s.controller.CallTool(ctx, request.ToolName, request.Arguments)
if err != nil {
+ // Spec 093 FR-011: a concurrency-limiter shed is backpressure, not a
+ // server fault — answer 429 with a Retry-After derived from the shedding
+ // scope's effective queue_timeout so a client can back off correctly.
+ var limitErr *limiter.LimitError
+ if errors.As(err, &limitErr) &&
+ (limitErr.Reason == limiter.ReasonQueueFull || limitErr.Reason == limiter.ReasonQueueTimeout) {
+ w.Header().Set("Retry-After", strconv.Itoa(retryAfterSeconds(limitErr.RetryAfter)))
+ s.logger.Warnw("Tool call shed by concurrency limiter",
+ "tool", request.ToolName,
+ "scope", string(limitErr.Scope),
+ "reason", string(limitErr.Reason))
+ s.writeError(w, r, http.StatusTooManyRequests, limitErr.UserMessage())
+ return
+ }
s.logger.Error("Failed to call tool", "tool", request.ToolName, "error", err)
s.writeError(w, r, http.StatusInternalServerError, fmt.Sprintf("Failed to call tool: %v", err))
return
diff --git a/internal/observability/metrics.go b/internal/observability/metrics.go
index d23432348..d8a3f06c0 100644
--- a/internal/observability/metrics.go
+++ b/internal/observability/metrics.go
@@ -46,6 +46,11 @@ type MetricsManager struct {
// Quarantine event metrics (MCP-32)
quarantineEvents *prometheus.CounterVec
+
+ // Concurrency limiter metrics (spec 093, FR-013)
+ toolCallsRejected *prometheus.CounterVec
+ concurrencyQueued *prometheus.GaugeVec
+ concurrencyActive *prometheus.GaugeVec
}
// NewMetricsManager creates a new metrics manager
@@ -234,6 +239,31 @@ func (mm *MetricsManager) initMetrics() {
},
[]string{"scope", "action"},
)
+
+ // Concurrency limiter metrics (spec 093, FR-013)
+ mm.toolCallsRejected = prometheus.NewCounterVec(
+ prometheus.CounterOpts{
+ Name: "mcpproxy_tool_calls_rejected_total",
+ Help: "Total number of tool calls shed by a concurrency limit",
+ },
+ []string{"server", "reason", "scope"},
+ )
+
+ mm.concurrencyQueued = prometheus.NewGaugeVec(
+ prometheus.GaugeOpts{
+ Name: "mcpproxy_concurrency_queue_depth",
+ Help: "Tool calls currently waiting for a concurrency slot",
+ },
+ []string{"scope", "server"},
+ )
+
+ mm.concurrencyActive = prometheus.NewGaugeVec(
+ prometheus.GaugeOpts{
+ Name: "mcpproxy_concurrency_active",
+ Help: "Tool calls currently holding a concurrency slot",
+ },
+ []string{"scope", "server"},
+ )
}
// registerMetrics registers all metrics with the registry
@@ -264,6 +294,10 @@ func (mm *MetricsManager) registerMetrics() {
mm.oauthRefreshDuration,
// Quarantine event metrics (MCP-32)
mm.quarantineEvents,
+ // Concurrency limiter metrics (spec 093)
+ mm.toolCallsRejected,
+ mm.concurrencyQueued,
+ mm.concurrencyActive,
)
// Also register Go runtime metrics
@@ -422,3 +456,37 @@ func (mm *MetricsManager) RecordOAuthRefreshDuration(server, result string, dura
func (mm *MetricsManager) RecordQuarantineEvent(scope, action string) {
mm.quarantineEvents.WithLabelValues(scope, action).Inc()
}
+
+// RecordToolCallRejected counts one call shed by a concurrency limiter (spec
+// 093 FR-013). reason is queue_full | queue_timeout; scope is server | global.
+// server is the call's TARGET even for a global shed — a rejection an operator
+// cannot attribute to a workload is not actionable.
+func (mm *MetricsManager) RecordToolCallRejected(server, reason, scope string) {
+ if mm == nil || mm.toolCallsRejected == nil {
+ return
+ }
+ mm.toolCallsRejected.WithLabelValues(server, reason, scope).Inc()
+}
+
+// SetConcurrencyDepth publishes one scope's live occupancy: how many calls are
+// running and how many are waiting for a slot (spec 093 FR-013). Sampled
+// periodically rather than written on every acquire/release — the gauges exist
+// to show sustained saturation, and per-call writes would put a metrics
+// mutex on the hot path.
+func (mm *MetricsManager) SetConcurrencyDepth(scope, server string, running, queued int) {
+ if mm == nil || mm.concurrencyQueued == nil {
+ return
+ }
+ mm.concurrencyActive.WithLabelValues(scope, server).Set(float64(running))
+ mm.concurrencyQueued.WithLabelValues(scope, server).Set(float64(queued))
+}
+
+// ResetConcurrencyDepth drops every concurrency gauge series. Called before a
+// fresh sample so a server that has been removed stops reporting a stale depth.
+func (mm *MetricsManager) ResetConcurrencyDepth() {
+ if mm == nil || mm.concurrencyQueued == nil {
+ return
+ }
+ mm.concurrencyActive.Reset()
+ mm.concurrencyQueued.Reset()
+}
diff --git a/internal/runtime/activity_service.go b/internal/runtime/activity_service.go
index baaa9eeb9..300d577fc 100644
--- a/internal/runtime/activity_service.go
+++ b/internal/runtime/activity_service.go
@@ -391,14 +391,19 @@ func (s *ActivityService) Stop() {
s.stopped = true
started := s.started
s.startMu.Unlock()
- if !started {
- return
- }
+
// Main loop exit (closes done AFTER the shutdown flush). All workersWG.Add
// calls happen before done closes — the loop goroutines are registered at
// the top of Start and detection goroutines are only spawned from the event
// loop — so Wait below cannot race an Add.
- <-s.done
+ if started {
+ <-s.done
+ }
+ // Waited unconditionally: writes admitted through enterWrite run on OTHER
+ // goroutines (a shed records its activity row inline, spec 093 FR-012) and
+ // exist whether or not the event loop was ever started. Any such writer that
+ // passed the stopped check above is already registered here; one arriving
+ // later is turned away, so this returns only when the DB has no writer left.
s.workersWG.Wait()
}
@@ -407,6 +412,10 @@ func (s *ActivityService) handleEvent(evt Event) {
switch evt.Type {
case EventTypeActivityToolCallCompleted:
s.handleToolCallCompleted(evt)
+ case EventTypeActivityToolCallRejected:
+ // Persisted synchronously by RecordToolCallRejected at the rejection
+ // site (spec 093 FR-012): the bus copy exists only for live subscribers,
+ // and handling it here too would write the row twice.
case EventTypeActivityPolicyDecision:
s.handlePolicyDecision(evt)
case EventTypeActivityQuarantineChange:
@@ -439,6 +448,97 @@ func (s *ActivityService) handleEvent(evt Event) {
}
}
+// enterWrite admits a BBolt write that runs on a caller's goroutine rather than
+// on one of this service's own loops. It reports false once shutdown has begun.
+//
+// The stopped check and the workersWG.Add MUST happen under the same lock Stop
+// takes, and in that order: Stop marks stopped under startMu and only then
+// waits on workersWG, so a writer that got in first is registered before the
+// wait starts, and one that arrives later sees stopped and never touches the
+// DB. Checking and registering separately would leave exactly the window where
+// Stop returns — and the DB closes — with a write in flight.
+//
+// Callers must defer workersWG.Done() when this returns true.
+func (s *ActivityService) enterWrite() bool {
+ s.startMu.Lock()
+ defer s.startMu.Unlock()
+ if s.stopped {
+ return false
+ }
+ s.workersWG.Add(1)
+ return true
+}
+
+// RecordToolCallRejected persists a concurrency-limiter shed as a tool_call
+// record with the dedicated "rejected" status (spec 093 FR-012). It is a
+// separate path from handleToolCallCompleted because a shed has no upstream
+// response, no token metrics and no intent envelope — only the rejection
+// metadata an operator needs to right-size the limits.
+//
+// It runs SYNCHRONOUSLY on the rejecting goroutine rather than on the activity
+// event loop: the event bus drops events for a subscriber that falls behind,
+// and a shed burst is exactly when it does. The cost is one BBolt write on an
+// error path that is already returning without calling the upstream.
+//
+// Spec 080 FR-010: because the write lives on someone else's goroutine, it must
+// join the same shutdown barrier as every other BBolt writer this service owns.
+// enterWrite registers it in workersWG under the lock Stop coordinates with, so
+// a write either happens entirely before Stop returns or does not happen at all
+// — it can never straddle the DB close.
+func (s *ActivityService) RecordToolCallRejected(evt Event) {
+ if s == nil || s.storage == nil {
+ return
+ }
+ if !s.enterWrite() {
+ return
+ }
+ defer s.workersWG.Done()
+
+ serverName := getStringPayload(evt.Payload, "server_name")
+ toolName := getStringPayload(evt.Payload, "tool_name")
+ source := getStringPayload(evt.Payload, "source")
+
+ activitySource := storage.ActivitySourceMCP
+ if source != "" {
+ activitySource = storage.ActivitySource(source)
+ }
+
+ metadata := map[string]interface{}{
+ storage.MetadataKeyRejectionReason: getStringPayload(evt.Payload, "reason"),
+ storage.MetadataKeyRejectionScope: getStringPayload(evt.Payload, "scope"),
+ }
+ if limit := getInt64Payload(evt.Payload, "limit"); limit > 0 {
+ metadata[storage.MetadataKeyRejectionLimit] = limit
+ }
+ if retryAfter := getInt64Payload(evt.Payload, "retry_after_ms"); retryAfter > 0 {
+ metadata[storage.MetadataKeyRejectionRetryAfterMs] = retryAfter
+ }
+
+ record := &storage.ActivityRecord{
+ Type: storage.ActivityTypeToolCall,
+ Source: activitySource,
+ ServerName: serverName,
+ ToolName: toolName,
+ Status: storage.ActivityStatusRejected,
+ ErrorMessage: getStringPayload(evt.Payload, "message"),
+ DurationMs: getInt64Payload(evt.Payload, "duration_ms"),
+ Timestamp: evt.Timestamp,
+ RequestID: getStringPayload(evt.Payload, "request_id"),
+ Metadata: metadata,
+ }
+
+ if err := s.storage.SaveActivity(record); err != nil {
+ s.logger.Error("Failed to save rejected activity record",
+ zap.Error(err),
+ zap.String("server_name", serverName),
+ zap.String("tool_name", toolName))
+ return
+ }
+ if s.usage != nil {
+ s.usage.Apply(record)
+ }
+}
+
// handleToolCallCompleted persists a tool call completion event.
func (s *ActivityService) handleToolCallCompleted(evt Event) {
serverName := getStringPayload(evt.Payload, "server_name")
diff --git a/internal/runtime/concurrency_rejections.go b/internal/runtime/concurrency_rejections.go
new file mode 100644
index 000000000..f584b9a8b
--- /dev/null
+++ b/internal/runtime/concurrency_rejections.go
@@ -0,0 +1,92 @@
+package runtime
+
+import (
+ "context"
+
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/reqcontext"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/upstream/limiter"
+)
+
+// RejectionMetricSink counts one shed call. It runs on the caller's goroutine
+// at the moment of rejection, so implementations must be non-blocking (a
+// Prometheus counter increment is).
+type RejectionMetricSink func(server, reason, scope string)
+
+// SetRejectionMetricSink installs the synchronous rejection counter (spec 093
+// FR-013). Passing nil detaches it.
+func (r *Runtime) SetRejectionMetricSink(sink RejectionMetricSink) {
+ if r == nil {
+ return
+ }
+ if sink == nil {
+ r.rejectionMetric.Store(nil)
+ return
+ }
+ r.rejectionMetric.Store(&sink)
+}
+
+// installRejectionObserver wires the concurrency limiter's shed seam to the
+// activity log and the rejection counter (spec 093 FR-012/FR-013). The observer
+// runs at the admission point inside the managed client, which is BELOW the MCP
+// dispatch layer — that is what makes the "rejected" record origin-independent:
+// sandboxed code-execution scripts and activity replay never touch
+// internal/server, yet their sheds land in the activity log with the same shape
+// as an MCP one.
+//
+// Both effects are SYNCHRONOUS here, not projections of the event bus. The bus
+// drops events when a subscriber's channel is full, which under a burst of
+// sheds — the only load where these numbers matter — would silently lose
+// exactly the rows and counter increments an operator is looking at.
+func (r *Runtime) installRejectionObserver() {
+ if r == nil || r.upstreamManager == nil {
+ return
+ }
+ r.upstreamManager.SetRejectionObserver(func(ctx context.Context, rej limiter.Rejection) {
+ if sink := r.rejectionMetric.Load(); sink != nil {
+ (*sink)(rej.Server, string(rej.Reason), string(rej.Scope))
+ }
+ r.EmitActivityToolCallRejected(
+ rej.Server,
+ rej.Tool,
+ activitySourceFromContext(ctx),
+ reqcontext.GetRequestID(ctx),
+ string(rej.Reason),
+ string(rej.Scope),
+ rej.Message,
+ rej.Limit,
+ rej.RetryAfter.Milliseconds(),
+ rej.Waited.Milliseconds(),
+ )
+ })
+}
+
+// ConcurrencyStats reports the live occupancy of the global aggregate limiter
+// and of every per-server limiter (spec 093 FR-013). Sampled by the metrics
+// bridge to publish the queue-depth gauges.
+func (r *Runtime) ConcurrencyStats() (global limiter.Stats, servers map[string]limiter.Stats) {
+ if r == nil || r.upstreamManager == nil {
+ return limiter.Stats{}, nil
+ }
+ return r.upstreamManager.ConcurrencyStats()
+}
+
+// activitySourceFromContext maps the request-source context value onto the
+// activity-log source vocabulary, matching what the MCP dispatch layer records.
+// An unset source means the call did not come through an external surface: that
+// is code execution or activity replay, which are internal.
+func activitySourceFromContext(ctx context.Context) string {
+ switch reqcontext.GetRequestSource(ctx) {
+ case reqcontext.SourceCLI:
+ return "cli"
+ case reqcontext.SourceRESTAPI:
+ return "api"
+ case reqcontext.SourceMCP:
+ return "mcp"
+ case reqcontext.SourceInternal:
+ return "internal"
+ case reqcontext.SourceUnknown:
+ return "internal"
+ default:
+ return "internal"
+ }
+}
diff --git a/internal/runtime/concurrency_rejections_test.go b/internal/runtime/concurrency_rejections_test.go
new file mode 100644
index 000000000..b5ddded7f
--- /dev/null
+++ b/internal/runtime/concurrency_rejections_test.go
@@ -0,0 +1,325 @@
+package runtime
+
+import (
+ "context"
+ "go/ast"
+ "go/parser"
+ "go/token"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.uber.org/zap"
+
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/reqcontext"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/storage"
+)
+
+// TestHandleToolCallRejected_PersistsRejectedRecord is the FR-012 storage
+// contract: a shed lands as a tool_call record with the dedicated "rejected"
+// status and the metadata an operator needs to right-size limits.
+func TestHandleToolCallRejected_PersistsRejectedRecord(t *testing.T) {
+ store, cleanup := setupTestStorage(t)
+ defer cleanup()
+
+ svc := NewActivityService(store, zap.NewNop())
+
+ svc.RecordToolCallRejected(Event{
+ Type: EventTypeActivityToolCallRejected,
+ Timestamp: time.Now().UTC(),
+ Payload: map[string]any{
+ "server_name": "analytics-db",
+ "tool_name": "query",
+ "source": "internal",
+ "request_id": "req-42",
+ "reason": "queue_timeout",
+ "scope": "server",
+ "message": "Server \"analytics-db\" is busy",
+ "limit": 2,
+ "retry_after_ms": int64(30000),
+ "duration_ms": int64(30001),
+ },
+ })
+
+ records, _, err := store.ListActivities(storage.DefaultActivityFilter())
+ require.NoError(t, err)
+ require.Len(t, records, 1)
+
+ rec := records[0]
+ assert.Equal(t, storage.ActivityTypeToolCall, rec.Type)
+ assert.Equal(t, storage.ActivityStatusRejected, rec.Status)
+ assert.Equal(t, "analytics-db", rec.ServerName)
+ assert.Equal(t, "query", rec.ToolName)
+ assert.Equal(t, storage.ActivitySource("internal"), rec.Source)
+ assert.Equal(t, "req-42", rec.RequestID)
+ assert.Equal(t, int64(30001), rec.DurationMs)
+
+ require.NotNil(t, rec.Metadata)
+ assert.Equal(t, "queue_timeout", rec.Metadata[storage.MetadataKeyRejectionReason])
+ assert.Equal(t, "server", rec.Metadata[storage.MetadataKeyRejectionScope])
+ assert.EqualValues(t, 2, toInt64(rec.Metadata[storage.MetadataKeyRejectionLimit]))
+ assert.EqualValues(t, 30000, toInt64(rec.Metadata[storage.MetadataKeyRejectionRetryAfterMs]))
+}
+
+// TestRejectedRecordIsWrittenOnceOffTheBus guards the non-lossy path: the row is
+// written at the rejection site, and the event-bus copy — which exists only so
+// live subscribers see the shed — must not write a second one.
+func TestRejectedRecordIsWrittenOnceOffTheBus(t *testing.T) {
+ store, cleanup := setupTestStorage(t)
+ defer cleanup()
+
+ svc := NewActivityService(store, zap.NewNop())
+ evt := Event{
+ Type: EventTypeActivityToolCallRejected,
+ Timestamp: time.Now().UTC(),
+ Payload: map[string]any{
+ "server_name": "db",
+ "tool_name": "query",
+ "reason": "queue_full",
+ "scope": "server",
+ },
+ }
+
+ svc.RecordToolCallRejected(evt)
+ svc.handleEvent(evt)
+
+ records, _, err := store.ListActivities(storage.DefaultActivityFilter())
+ require.NoError(t, err)
+ assert.Len(t, records, 1, "the bus copy must not persist a duplicate rejected row")
+}
+
+// TestStopWaitsForInflightRejectionWrites is the Spec 080 FR-010 barrier for the
+// synchronous rejection writer. The write runs on the rejecting caller's
+// goroutine, so unless it joins the same wait group Stop drains, Stop can return
+// — and Runtime.Close can resolve the shutdown marker and close BBolt — with a
+// write still in flight.
+func TestStopWaitsForInflightRejectionWrites(t *testing.T) {
+ store, cleanup := setupTestStorage(t)
+ defer cleanup()
+
+ svc := NewActivityService(store, zap.NewNop())
+
+ // A writer admitted through the barrier before Stop begins must be waited on.
+ entered := make(chan struct{})
+ var finished atomic.Bool
+ go func() {
+ if !svc.enterWrite() {
+ close(entered)
+ return
+ }
+ close(entered)
+ time.Sleep(150 * time.Millisecond)
+ finished.Store(true)
+ svc.workersWG.Done()
+ }()
+ <-entered
+
+ svc.Stop()
+ assert.True(t, finished.Load(),
+ "Stop returned while a registered activity write was still in flight")
+
+ // Once Stop has returned, the DB may close at any moment: further rejections
+ // must be turned away rather than written.
+ countRejected := func() int {
+ records, _, err := store.ListActivities(storage.DefaultActivityFilter())
+ require.NoError(t, err)
+ return len(records)
+ }
+ before := countRejected()
+
+ var wg sync.WaitGroup
+ for i := 0; i < 8; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ svc.RecordToolCallRejected(Event{
+ Type: EventTypeActivityToolCallRejected,
+ Timestamp: time.Now().UTC(),
+ Payload: map[string]any{
+ "server_name": "db",
+ "tool_name": "query",
+ "reason": "queue_full",
+ "scope": "server",
+ },
+ })
+ }()
+ }
+ wg.Wait()
+
+ assert.Equal(t, before, countRejected(), "no activity write may land after Stop returns")
+}
+
+// toInt64 normalises a JSON-round-tripped number (float64) or a native int64.
+func toInt64(v interface{}) int64 {
+ switch n := v.(type) {
+ case int64:
+ return n
+ case int:
+ return int64(n)
+ case float64:
+ return int64(n)
+ default:
+ return -1
+ }
+}
+
+// TestUsageAggregate_RejectedDoesNotInflateCalls covers the usage-aggregation
+// half of FR-012: a shed never executed, so it must not pollute call counts,
+// latency percentiles or the executed-call timeline.
+func TestUsageAggregate_RejectedDoesNotInflateCalls(t *testing.T) {
+ agg := newUsageAggregate()
+
+ agg.Apply(&storage.ActivityRecord{
+ Type: storage.ActivityTypeToolCall,
+ ServerName: "db",
+ ToolName: "query",
+ Status: storage.ActivityStatusSuccess,
+ DurationMs: 12,
+ Timestamp: time.Now().UTC(),
+ })
+ agg.Apply(&storage.ActivityRecord{
+ Type: storage.ActivityTypeToolCall,
+ ServerName: "db",
+ ToolName: "query",
+ Status: storage.ActivityStatusRejected,
+ DurationMs: 30000, // the queue wait, not an execution time
+ Timestamp: time.Now().UTC(),
+ })
+
+ tu := agg.tool("db", "query")
+ assert.EqualValues(t, 1, tu.Calls, "a shed never executed and must not count as a call")
+ assert.EqualValues(t, 0, tu.Errors, "a shed is not an upstream error")
+ assert.EqualValues(t, 1, tu.Rejected)
+
+ var timelineCalls int64
+ for _, b := range agg.Timeline() {
+ timelineCalls += b.Calls
+ }
+ assert.EqualValues(t, 1, timelineCalls, "the executed-call timeline must exclude sheds")
+}
+
+// TestActivitySourceFromContext maps request sources onto the activity-log
+// vocabulary. An unset source means the call never crossed an external surface
+// — code execution or activity replay — which is "internal", not "mcp".
+func TestActivitySourceFromContext(t *testing.T) {
+ assert.Equal(t, "cli", activitySourceFromContext(reqcontext.WithRequestSource(context.Background(), reqcontext.SourceCLI)))
+ assert.Equal(t, "api", activitySourceFromContext(reqcontext.WithRequestSource(context.Background(), reqcontext.SourceRESTAPI)))
+ assert.Equal(t, "mcp", activitySourceFromContext(reqcontext.WithRequestSource(context.Background(), reqcontext.SourceMCP)))
+ assert.Equal(t, "internal", activitySourceFromContext(context.Background()))
+}
+
+// TestReplayToolCall_CreatesNoExecutionTimeoutBeforeDispatch is the FR-005
+// structural guard for activity replay.
+//
+// Replay used to wrap client.CallTool in a CallToolTimeout context created
+// BEFORE dispatch, so any time the call spent waiting in a concurrency-limiter
+// queue was subtracted from its execution budget. The execution timeout now
+// starts after admission (inside core.Client.CallTool), and the only way to
+// keep it that way is to assert replay does not re-introduce a pre-dispatch
+// deadline — a behavioural test cannot see the difference without a real slow
+// upstream.
+func TestReplayToolCall_CreatesNoExecutionTimeoutBeforeDispatch(t *testing.T) {
+ fset := token.NewFileSet()
+ file, err := parser.ParseFile(fset, "runtime.go", nil, parser.ParseComments)
+ require.NoError(t, err)
+
+ var body *ast.BlockStmt
+ ast.Inspect(file, func(n ast.Node) bool {
+ fn, ok := n.(*ast.FuncDecl)
+ if !ok || fn.Name.Name != "ReplayToolCall" || fn.Body == nil {
+ return true
+ }
+ body = fn.Body
+ return false
+ })
+ require.NotNil(t, body, "ReplayToolCall not found in runtime.go")
+
+ var foundTimeout, foundCall bool
+ ast.Inspect(body, func(n ast.Node) bool {
+ call, ok := n.(*ast.CallExpr)
+ if !ok {
+ return true
+ }
+ sel, ok := call.Fun.(*ast.SelectorExpr)
+ if !ok {
+ return true
+ }
+ pkg, ok := sel.X.(*ast.Ident)
+ if !ok {
+ return true
+ }
+ if pkg.Name == "context" && (sel.Sel.Name == "WithTimeout" || sel.Sel.Name == "WithDeadline") {
+ foundTimeout = true
+ }
+ if pkg.Name == "client" && sel.Sel.Name == "CallTool" {
+ foundCall = true
+ }
+ return true
+ })
+
+ assert.True(t, foundCall, "replay must still dispatch through the managed client (that is where admission lives)")
+ assert.False(t, foundTimeout,
+ "ReplayToolCall must not create an execution deadline before dispatch: queue wait would eat the execution budget (FR-005)")
+}
+
+// TestReplayToolCall_ReleasesRuntimeLockBeforeDispatch is the FR-008 structural
+// guard for the same function. Replay held r.mu.RLock for its whole duration,
+// so once replay started queueing behind a concurrency limit it also blocked
+// ApplyConfig and every other writer for the queue-plus-execution duration.
+// The lock must be released before the dispatch, not deferred past it.
+func TestReplayToolCall_ReleasesRuntimeLockBeforeDispatch(t *testing.T) {
+ fset := token.NewFileSet()
+ file, err := parser.ParseFile(fset, "runtime.go", nil, parser.ParseComments)
+ require.NoError(t, err)
+
+ var body *ast.BlockStmt
+ ast.Inspect(file, func(n ast.Node) bool {
+ fn, ok := n.(*ast.FuncDecl)
+ if !ok || fn.Name.Name != "ReplayToolCall" || fn.Body == nil {
+ return true
+ }
+ body = fn.Body
+ return false
+ })
+ require.NotNil(t, body, "ReplayToolCall not found in runtime.go")
+
+ isMuCall := func(call *ast.CallExpr, method string) bool {
+ sel, ok := call.Fun.(*ast.SelectorExpr)
+ if !ok || sel.Sel.Name != method {
+ return false
+ }
+ inner, ok := sel.X.(*ast.SelectorExpr)
+ return ok && inner.Sel.Name == "mu"
+ }
+
+ var unlockPos, callPos token.Pos
+ var deferredUnlock bool
+ ast.Inspect(body, func(n ast.Node) bool {
+ switch node := n.(type) {
+ case *ast.DeferStmt:
+ if isMuCall(node.Call, "RUnlock") || isMuCall(node.Call, "Unlock") {
+ deferredUnlock = true
+ }
+ case *ast.CallExpr:
+ if isMuCall(node, "RUnlock") && !unlockPos.IsValid() {
+ unlockPos = node.Pos()
+ }
+ if sel, ok := node.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "CallTool" {
+ if pkg, ok := sel.X.(*ast.Ident); ok && pkg.Name == "client" {
+ callPos = node.Pos()
+ }
+ }
+ }
+ return true
+ })
+
+ assert.False(t, deferredUnlock,
+ "ReplayToolCall must not defer the runtime lock past the upstream call (FR-008)")
+ require.True(t, unlockPos.IsValid(), "ReplayToolCall must release the runtime read lock explicitly")
+ require.True(t, callPos.IsValid(), "ReplayToolCall must dispatch through the managed client")
+ assert.Less(t, int(unlockPos), int(callPos),
+ "the runtime lock must be released BEFORE dispatch, so a queued replay cannot stall config reload (FR-008)")
+}
diff --git a/internal/runtime/config_hotreload.go b/internal/runtime/config_hotreload.go
index 75b8fab94..4859c1a9e 100644
--- a/internal/runtime/config_hotreload.go
+++ b/internal/runtime/config_hotreload.go
@@ -138,6 +138,26 @@ func DetectConfigChanges(oldCfg, newCfg *config.Config) *ConfigApplyResult {
result.ChangedFields = append(result.ChangedFields, "tool_discovery_interval")
}
+ // Concurrency limits (spec 093 / GH #955 — hot-reloadable, FR-021). The
+ // limiter registry re-publishes one generation from the new snapshot on
+ // apply; occupancy is shared across generations, so running calls are never
+ // interrupted. These clauses cover the GLOBAL aggregate limiter and the
+ // per-server default set — per-server overrides are already covered by the
+ // Servers DeepEqual above. Without them a lone limit edit computes empty
+ // ChangedFields and is swallowed as "no changes detected".
+ if !reflect.DeepEqual(oldCfg.MaxConcurrentRequests, newCfg.MaxConcurrentRequests) {
+ result.ChangedFields = append(result.ChangedFields, "max_concurrent_requests")
+ }
+ if !reflect.DeepEqual(oldCfg.QueueSize, newCfg.QueueSize) {
+ result.ChangedFields = append(result.ChangedFields, "queue_size")
+ }
+ if !reflect.DeepEqual(oldCfg.QueueTimeout, newCfg.QueueTimeout) {
+ result.ChangedFields = append(result.ChangedFields, "queue_timeout")
+ }
+ if !reflect.DeepEqual(oldCfg.ServerConcurrencyDefaults, newCfg.ServerConcurrencyDefaults) {
+ result.ChangedFields = append(result.ChangedFields, "server_concurrency_defaults")
+ }
+
// Logging configuration (can be hot-reloaded)
if !reflect.DeepEqual(oldCfg.Logging, newCfg.Logging) {
result.ChangedFields = append(result.ChangedFields, "logging")
diff --git a/internal/runtime/config_hotreload_test.go b/internal/runtime/config_hotreload_test.go
index c5f200fdc..f43582b81 100644
--- a/internal/runtime/config_hotreload_test.go
+++ b/internal/runtime/config_hotreload_test.go
@@ -554,3 +554,72 @@ func TestDetectConfigChanges_TrustedHosts(t *testing.T) {
assert.NotContains(t, result.ChangedFields, "trusted_hosts")
})
}
+
+// TestDetectConfigChanges_ConcurrencyLimits (spec 093, FR-021): the global
+// aggregate limiter fields and the per-server default set must be reported as
+// hot-reloadable changes, otherwise a lone limit edit falls through as
+// "no changes detected" and the limiter registry is never re-applied.
+// Per-server overrides ride the Servers DeepEqual clause.
+func TestDetectConfigChanges_ConcurrencyLimits(t *testing.T) {
+ mk := func(mutate func(*config.Config)) *config.Config {
+ c := &config.Config{Listen: "127.0.0.1:8080", DataDir: "/d", TLS: &config.TLSConfig{}}
+ if mutate != nil {
+ mutate(c)
+ }
+ return c
+ }
+ intp := func(v int) *int { return &v }
+ durp := func(d time.Duration) *config.Duration { v := config.Duration(d); return &v }
+
+ t.Run("max_concurrent_requests change detected", func(t *testing.T) {
+ result := DetectConfigChanges(
+ mk(nil),
+ mk(func(c *config.Config) { c.MaxConcurrentRequests = intp(10) }))
+ require.True(t, result.Success)
+ assert.Contains(t, result.ChangedFields, "max_concurrent_requests")
+ assert.False(t, result.RequiresRestart, "concurrency limits are hot-reloadable")
+ })
+
+ t.Run("queue_size change detected", func(t *testing.T) {
+ result := DetectConfigChanges(
+ mk(func(c *config.Config) { c.MaxConcurrentRequests = intp(10); c.QueueSize = intp(1) }),
+ mk(func(c *config.Config) { c.MaxConcurrentRequests = intp(10); c.QueueSize = intp(5) }))
+ require.True(t, result.Success)
+ assert.Contains(t, result.ChangedFields, "queue_size")
+ })
+
+ t.Run("queue_timeout change detected", func(t *testing.T) {
+ result := DetectConfigChanges(
+ mk(func(c *config.Config) { c.QueueTimeout = durp(30 * time.Second) }),
+ mk(func(c *config.Config) { c.QueueTimeout = durp(5 * time.Second) }))
+ require.True(t, result.Success)
+ assert.Contains(t, result.ChangedFields, "queue_timeout")
+ })
+
+ t.Run("server_concurrency_defaults change detected", func(t *testing.T) {
+ result := DetectConfigChanges(
+ mk(nil),
+ mk(func(c *config.Config) {
+ c.ServerConcurrencyDefaults = &config.ConcurrencyDefaults{MaxConcurrentRequests: intp(5)}
+ }))
+ require.True(t, result.Success)
+ assert.Contains(t, result.ChangedFields, "server_concurrency_defaults")
+ })
+
+ t.Run("unchanged limits not reported", func(t *testing.T) {
+ build := func() *config.Config {
+ return mk(func(c *config.Config) {
+ c.MaxConcurrentRequests = intp(10)
+ c.QueueSize = intp(5)
+ c.QueueTimeout = durp(30 * time.Second)
+ c.ServerConcurrencyDefaults = &config.ConcurrencyDefaults{MaxConcurrentRequests: intp(5)}
+ })
+ }
+ result := DetectConfigChanges(build(), build())
+ require.True(t, result.Success)
+ assert.NotContains(t, result.ChangedFields, "max_concurrent_requests")
+ assert.NotContains(t, result.ChangedFields, "queue_size")
+ assert.NotContains(t, result.ChangedFields, "queue_timeout")
+ assert.NotContains(t, result.ChangedFields, "server_concurrency_defaults")
+ })
+}
diff --git a/internal/runtime/event_bus.go b/internal/runtime/event_bus.go
index 8bc852339..6ca099040 100644
--- a/internal/runtime/event_bus.go
+++ b/internal/runtime/event_bus.go
@@ -493,6 +493,37 @@ func (r *Runtime) EmitActivityToolCallCompleted(serverName, toolName, sessionID,
r.publishEvent(newEvent(EventTypeActivityToolCallCompleted, payload))
}
+// EmitActivityToolCallRejected emits an event when a concurrency limiter shed a
+// tool call (spec 093 FR-012). This is the ORIGIN-INDEPENDENT rejection seam:
+// it is invoked from the limiter observer inside the managed client, so a shed
+// is recorded identically whether the call came from an MCP tool-call variant,
+// the REST endpoint, a sandboxed code-execution script or an activity replay.
+//
+// reason is queue_full | queue_timeout; scope is server | global.
+// Unlike every other activity emission, the RECORD is written synchronously
+// here rather than by the activity service's bus subscriber: publishEvent drops
+// events for any subscriber whose channel is full, and a burst of sheds is
+// precisely the load that fills it. The bus copy is still published, but only
+// so live subscribers (SSE, tray) see the rejection — the durable row no longer
+// depends on it.
+func (r *Runtime) EmitActivityToolCallRejected(serverName, toolName, source, requestID, reason, scope, message string, limit int, retryAfterMs, waitedMs int64) {
+ payload := map[string]any{
+ "server_name": serverName,
+ "tool_name": toolName,
+ "source": source,
+ "request_id": requestID,
+ "reason": reason,
+ "scope": scope,
+ "message": message,
+ "limit": limit,
+ "retry_after_ms": retryAfterMs,
+ "duration_ms": waitedMs,
+ }
+ evt := newEvent(EventTypeActivityToolCallRejected, payload)
+ r.activityService.RecordToolCallRejected(evt)
+ r.publishEvent(evt)
+}
+
// EmitActivityPolicyDecision emits an event when a policy blocks a tool call.
//
// requestID is the dispatch's correlation id, and it is what lets a consumer
diff --git a/internal/runtime/events.go b/internal/runtime/events.go
index b2f192256..f0dffd370 100644
--- a/internal/runtime/events.go
+++ b/internal/runtime/events.go
@@ -28,6 +28,12 @@ const (
EventTypeActivityToolCallStarted EventType = "activity.tool_call.started"
// EventTypeActivityToolCallCompleted is emitted when a tool execution finishes.
EventTypeActivityToolCallCompleted EventType = "activity.tool_call.completed"
+ // EventTypeActivityToolCallRejected is emitted when a concurrency limiter
+ // sheds a tool call before it reaches the upstream (spec 093 FR-012). It is
+ // published from the limiter's origin-independent seam, so it also covers
+ // the dispatch paths that never pass through the MCP layer (sandboxed code
+ // execution, activity replay).
+ EventTypeActivityToolCallRejected EventType = "activity.tool_call.rejected"
// EventTypeActivityPolicyDecision is emitted when a policy blocks a tool call.
EventTypeActivityPolicyDecision EventType = "activity.policy_decision"
// EventTypeActivityQuarantineChange is emitted when a server's quarantine state changes.
diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go
index 61d847995..666b0c28f 100644
--- a/internal/runtime/runtime.go
+++ b/internal/runtime/runtime.go
@@ -28,6 +28,7 @@ import (
"github.com/smart-mcp-proxy/mcpproxy-go/internal/index"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/oauth"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/registries"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/reqcontext"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/runtime/configsvc"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/runtime/supervisor"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/secret"
@@ -41,6 +42,7 @@ import (
"github.com/smart-mcp-proxy/mcpproxy-go/internal/updatecheck"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/upstream"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/upstream/core"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/upstream/limiter"
)
// Status captures high-level state for API consumers.
@@ -126,6 +128,13 @@ type Runtime struct {
managementService interface{} // Initialized later to avoid import cycle
activityService *ActivityService // Activity logging service
+ // rejectionMetric counts a concurrency shed SYNCHRONOUSLY at the rejection
+ // site (spec 093 FR-013). Installed by the observability bridge. It is
+ // deliberately not driven off the event bus: the bus drops events when a
+ // subscriber falls behind, and a rejection burst — the one moment the
+ // counter matters — is exactly when that happens.
+ rejectionMetric atomic.Pointer[RejectionMetricSink]
+
// workSessions derives a unit of USER WORK from the churn of transport
// sessions underneath it (Spec 082).
workSessions *WorkSessionTracker
@@ -357,6 +366,11 @@ func New(cfg *config.Config, cfgPath string, logger *zap.Logger) (*Runtime, erro
// signals of a reconnect storm without noticeably delaying the result.
rt.scanNotify = newScanNotifyDebouncer(rt, 750*time.Millisecond)
+ // Spec 093 FR-012/FR-013: origin-independent shed seam. Installed here (not
+ // in the MCP dispatch layer) so code_execution and activity replay are
+ // covered by construction.
+ rt.installRejectionObserver()
+
return rt, nil
}
@@ -1175,20 +1189,37 @@ func (r *Runtime) GetServerToolCalls(serverName string, limit int) ([]*contracts
return contractCalls, nil
}
-// ReplayToolCall replays a tool call with modified arguments
-func (r *Runtime) ReplayToolCall(id string, arguments map[string]interface{}) (*contracts.ToolCallRecord, error) {
+// ReplayToolCall replays a tool call with modified arguments.
+//
+// ctx is the CALLER's context (the HTTP request's). It governs the whole
+// replay, including the wait for a concurrency slot: a client that disconnects
+// mid-queue releases the slot immediately instead of leaving a call queued for
+// a caller that is gone (FR-005).
+func (r *Runtime) ReplayToolCall(ctx context.Context, id string, arguments map[string]interface{}) (*contracts.ToolCallRecord, error) {
+ // Spec 093 FR-008: snapshot the collaborators under the lock and release it
+ // before anything that can block. Replay dispatches through the same
+ // admission seam as every other origin, so holding r.mu across the call
+ // would let one queued replay stall ApplyConfig and every other writer for
+ // the whole queue-plus-execution duration.
r.mu.RLock()
- defer r.mu.RUnlock()
+ storageManager := r.storageManager
+ upstreamManager := r.upstreamManager
+ cfgPath := r.cfgPath
+ r.mu.RUnlock()
+
+ if storageManager == nil || upstreamManager == nil {
+ return nil, fmt.Errorf("runtime is not ready to replay tool calls")
+ }
// Get the original tool call using the same pattern as GetToolCallByID
var originalCall *storage.ToolCallRecord
- identities, err := r.storageManager.ListServerIdentities()
+ identities, err := storageManager.ListServerIdentities()
if err != nil {
return nil, fmt.Errorf("failed to list server identities: %w", err)
}
for _, identity := range identities {
- calls, err := r.storageManager.GetServerToolCalls(identity.ID, 1000)
+ calls, err := storageManager.GetServerToolCalls(identity.ID, 1000)
if err != nil {
continue
}
@@ -1215,14 +1246,29 @@ func (r *Runtime) ReplayToolCall(id string, arguments map[string]interface{}) (*
}
// Get the upstream client
- client, ok := r.upstreamManager.GetClient(originalCall.ServerName)
+ client, ok := upstreamManager.GetClient(originalCall.ServerName)
if !ok || client == nil {
return nil, fmt.Errorf("server not found: %s", originalCall.ServerName)
}
- // Call the tool with modified arguments
- ctx, cancel := context.WithTimeout(context.Background(), r.cfg.CallToolTimeout.Duration())
- defer cancel()
+ // Call the tool with modified arguments.
+ //
+ // Spec 093 FR-005: NO execution-timeout context is created here. Replay used
+ // to wrap the call in a CallToolTimeout context before dispatch, which meant
+ // time spent waiting in a concurrency-limiter queue was subtracted from the
+ // call's execution budget — a replay that queued for 20s would get 20s less
+ // upstream time than the same call made from an agent. The execution timeout
+ // is applied by core.Client.CallTool AFTER admission, so passing the caller's
+ // context gives replay the same post-admission budget as every other origin.
+ // Cancellation still works: the caller's context governs the queue wait, and
+ // the deeper timeout governs execution.
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ // Spec 093 FR-012/P3: replay never crossed an external surface, so its sheds
+ // are attributed to the internal origin rather than to whichever surface
+ // asked for the replay.
+ ctx = reqcontext.WithRequestSource(ctx, reqcontext.SourceInternal)
startTime := time.Now()
result, callErr := client.CallTool(ctx, originalCall.ToolName, callArgs)
@@ -1237,7 +1283,7 @@ func (r *Runtime) ReplayToolCall(id string, arguments map[string]interface{}) (*
Arguments: callArgs,
Duration: duration.Nanoseconds(),
Timestamp: time.Now(),
- ConfigPath: r.cfgPath,
+ ConfigPath: cfgPath,
}
if callErr != nil {
@@ -1247,10 +1293,23 @@ func (r *Runtime) ReplayToolCall(id string, arguments map[string]interface{}) (*
}
// Store the new tool call
- if err := r.storageManager.RecordToolCall(newCall); err != nil {
+ if err := storageManager.RecordToolCall(newCall); err != nil {
r.logger.Warn("Failed to record replayed tool call", zap.Error(err))
}
+ // Spec 093 FR-011: a shed is backpressure, not a completed replay. Flattening
+ // it into the record's Error field and returning nil made the REST endpoint
+ // answer 200 success:true for a call that never ran; the typed identity is
+ // returned instead so the handler can map it to 429 + Retry-After. Other
+ // upstream errors keep the existing "replay ran, the tool failed" contract.
+ if callErr != nil {
+ var limitErr *limiter.LimitError
+ if errors.As(callErr, &limitErr) &&
+ (limitErr.Reason == limiter.ReasonQueueFull || limitErr.Reason == limiter.ReasonQueueTimeout) {
+ return nil, callErr
+ }
+ }
+
// Convert to contract type
return &contracts.ToolCallRecord{
ID: newCall.ID,
diff --git a/internal/runtime/usage_aggregate.go b/internal/runtime/usage_aggregate.go
index cfe12a000..43fe2c6d4 100644
--- a/internal/runtime/usage_aggregate.go
+++ b/internal/runtime/usage_aggregate.go
@@ -55,6 +55,7 @@ type ToolUsage struct {
Calls int64 `json:"calls"`
Errors int64 `json:"errors"`
Blocked int64 `json:"blocked"`
+ Rejected int64 `json:"rejected"` // spec 093: shed by a concurrency limit, never executed
ReqBytesSum int64 `json:"req_bytes_sum"`
RespBytesSum int64 `json:"resp_bytes_sum"`
SizedReqCalls int64 `json:"sized_req_calls"` // calls with RequestBytes>0
@@ -178,9 +179,15 @@ func (a *UsageAggregate) Apply(rec *storage.ActivityRecord) {
return
}
switch {
+ case rec.Type == storage.ActivityTypeToolCall && rec.Status == storage.ActivityStatusRejected:
+ // Spec 093: shed by a concurrency limit. Like a policy block it never
+ // executed, so it must not inflate Calls, latency percentiles, byte
+ // averages or the executed-call timeline.
+ a.applyRejected(rec)
+ return
case rec.Type == storage.ActivityTypeToolCall:
// folded below
- case rec.Type == storage.ActivityTypePolicyDecision && rec.Status == "blocked":
+ case rec.Type == storage.ActivityTypePolicyDecision && rec.Status == storage.ActivityStatusBlocked:
a.applyBlocked(rec)
return
default:
@@ -226,6 +233,17 @@ func (a *UsageAggregate) applyBlocked(rec *storage.ActivityRecord) {
}
}
+// applyRejected folds a concurrency-limiter shed into the per-tool Rejected
+// counter (spec 093 FR-012). Same shape as applyBlocked: the tool never ran, so
+// nothing but Rejected and LastUsed moves.
+func (a *UsageAggregate) applyRejected(rec *storage.ActivityRecord) {
+ tu := a.tool(rec.ServerName, rec.ToolName)
+ tu.Rejected++
+ if rec.Timestamp.After(tu.LastUsed) {
+ tu.LastUsed = rec.Timestamp
+ }
+}
+
func (a *UsageAggregate) applyTimeBucket(rec *storage.ActivityRecord) {
start := rec.Timestamp.UTC().Truncate(usageBucketWidth)
k := start.Unix()
diff --git a/internal/server/concurrency_rejection_rows_test.go b/internal/server/concurrency_rejection_rows_test.go
new file mode 100644
index 000000000..b0ebe7813
--- /dev/null
+++ b/internal/server/concurrency_rejection_rows_test.go
@@ -0,0 +1,45 @@
+package server
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestSingleRejectedRowEmitter guards the spec-093 de-duplication invariant from
+// the producing side. Every shed already produces one canonical "rejected"
+// tool_call record at the limiter, which is what makes the record
+// origin-independent (FR-012). The MCP variant handler adds one MCP-flavoured
+// internal_tool_call row on top, and the default activity filter hides exactly
+// that one (see storage.ActivityFilter).
+//
+// If another dispatch path in this package starts emitting a rejected activity
+// row, the filter can no longer collapse the pair and summaries double-count
+// sheds again — so the emitter count is pinned here.
+func TestSingleRejectedRowEmitter(t *testing.T) {
+ entries, err := filepath.Glob("*.go")
+ require.NoError(t, err)
+
+ emitters := map[string]int{}
+ for _, path := range entries {
+ if strings.HasSuffix(path, "_test.go") {
+ continue
+ }
+ src, err := os.ReadFile(path)
+ require.NoError(t, err)
+ for _, line := range strings.Split(string(src), "\n") {
+ if strings.Contains(line, "emitActivityInternalToolCall") &&
+ strings.Contains(line, "ActivityStatusRejected") {
+ emitters[path]++
+ }
+ }
+ }
+
+ assert.Equal(t, map[string]int{"mcp.go": 1}, emitters,
+ "exactly one rejected internal_tool_call emitter may exist (the MCP variant handler); "+
+ "code_execution and replay must rely on the limiter's canonical row alone")
+}
diff --git a/internal/server/concurrency_shed.go b/internal/server/concurrency_shed.go
new file mode 100644
index 000000000..a32880a64
--- /dev/null
+++ b/internal/server/concurrency_shed.go
@@ -0,0 +1,107 @@
+package server
+
+import (
+ "context"
+ "errors"
+ "sync"
+
+ "github.com/mark3labs/mcp-go/mcp"
+
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/upstream/limiter"
+)
+
+// asShed reports whether err is a concurrency-limiter SHED — queue full or
+// queue timeout. A server-unavailable rejection is not a shed: it means the
+// target was disabled/removed while the call was queued, which keeps the
+// existing "server not available" semantics (FR-009).
+func asShed(err error) (*limiter.LimitError, bool) {
+ var limitErr *limiter.LimitError
+ if !errors.As(err, &limitErr) {
+ return nil, false
+ }
+ switch limitErr.Reason {
+ case limiter.ReasonQueueFull, limiter.ReasonQueueTimeout:
+ return limitErr, true
+ case limiter.ReasonServerUnavailable:
+ return nil, false
+ default:
+ return nil, false
+ }
+}
+
+// shedMessage renders the agent-facing explanation of a shed (FR-010). The
+// wording lives on the typed error so the MCP result, the REST 429 body and the
+// activity record all read identically.
+func shedMessage(limitErr *limiter.LimitError) string {
+ return limitErr.UserMessage()
+}
+
+// shedToolResult turns a shed into a normal tool-call error RESULT (isError:true)
+// rather than a protocol error, so an agent session survives the rejection and
+// can retry (FR-010).
+func shedToolResult(limitErr *limiter.LimitError) *mcp.CallToolResult {
+ return mcp.NewToolResultError(shedMessage(limitErr))
+}
+
+// shedDispatchError carries the agent-readable shed message out of the MCP
+// dispatch layer while keeping the typed limiter identity reachable through
+// errors.As — which is what lets the REST handler answer 429 + Retry-After
+// (FR-011) instead of the blanket 500 it would produce from a flattened string.
+type shedDispatchError struct {
+ limitErr *limiter.LimitError
+ message string
+}
+
+func (e *shedDispatchError) Error() string { return e.message }
+
+func (e *shedDispatchError) Unwrap() error { return e.limitErr }
+
+// shedCapture is the side channel that carries a shed's TYPED identity out of
+// the MCP dispatch layer.
+//
+// The MCP contract forces the handlers to answer a shed with (result, nil) —
+// an isError result, never a transport error. But the REST endpoint has to map
+// the same shed to HTTP 429 + Retry-After (FR-011), and by the time
+// CallToolDirect sees the result the only thing left is a string. Rather than
+// re-parse that string, CallToolDirect installs this box in the context and the
+// handler drops the *limiter.LimitError into it on the way out.
+type shedCapture struct {
+ mu sync.Mutex
+ err *limiter.LimitError
+}
+
+type shedCaptureKeyType struct{}
+
+var shedCaptureKey shedCaptureKeyType
+
+// withShedCapture installs a capture box on ctx. Used by the REST dispatch
+// entry point (CallToolDirect); the MCP transport path does not install one, so
+// recordShed is a no-op there.
+func withShedCapture(ctx context.Context) (context.Context, *shedCapture) {
+ box := &shedCapture{}
+ return context.WithValue(ctx, shedCaptureKey, box), box
+}
+
+// recordShed stores the typed rejection on the context's capture box, if any.
+func recordShed(ctx context.Context, limitErr *limiter.LimitError) {
+ if ctx == nil || limitErr == nil {
+ return
+ }
+ box, ok := ctx.Value(shedCaptureKey).(*shedCapture)
+ if !ok || box == nil {
+ return
+ }
+ box.mu.Lock()
+ box.err = limitErr
+ box.mu.Unlock()
+}
+
+// take returns the captured rejection, if the dispatch shed the call.
+func (c *shedCapture) take() *limiter.LimitError {
+ if c == nil {
+ return nil
+ }
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.err
+}
diff --git a/internal/server/concurrency_shed_test.go b/internal/server/concurrency_shed_test.go
new file mode 100644
index 000000000..bc4037100
--- /dev/null
+++ b/internal/server/concurrency_shed_test.go
@@ -0,0 +1,225 @@
+package server
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/mark3labs/mcp-go/mcp"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.uber.org/zap"
+
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/reqcontext"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/secret"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/upstream"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/upstream/limiter"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/upstream/types"
+)
+
+func shedIntPtr(v int) *int { return &v }
+
+func shedDurPtr(d time.Duration) *config.Duration {
+ cd := config.Duration(d)
+ return &cd
+}
+
+// TestShedMessage_ServerScopeNamesServerAndLimit covers FR-010's per-server
+// wording: the message identifies the server, its cap, why it was shed, and
+// tells the agent to retry.
+func TestShedMessage_ServerScopeNamesServerAndLimit(t *testing.T) {
+ msg := shedMessage(&limiter.LimitError{
+ Scope: limiter.ScopeServer,
+ Reason: limiter.ReasonQueueFull,
+ Server: "analytics-db",
+ Limit: 2,
+ })
+
+ assert.Contains(t, msg, `"analytics-db"`)
+ assert.Contains(t, msg, "2")
+ assert.Contains(t, msg, "wait queue is full")
+ assert.Contains(t, msg, limiter.RetryAdvice)
+}
+
+// TestShedMessage_GlobalScopeNeverBlamesAServer covers FR-010's explicit rule.
+func TestShedMessage_GlobalScopeNeverBlamesAServer(t *testing.T) {
+ msg := shedMessage(&limiter.LimitError{
+ Scope: limiter.ScopeGlobal,
+ Reason: limiter.ReasonQueueTimeout,
+ Server: "", // a global rejection carries no server
+ Limit: 20,
+ })
+
+ assert.Contains(t, msg, "proxy-wide")
+ assert.Contains(t, msg, "20")
+ assert.Contains(t, msg, "queue timeout")
+ assert.Contains(t, msg, limiter.RetryAdvice)
+ assert.NotContains(t, strings.ToLower(msg), "server \"")
+}
+
+// TestShedToolResult_IsErrorResultNotProtocolError covers FR-010: an agent must
+// receive a readable tool-call error, not a transport failure.
+func TestShedToolResult_IsErrorResultNotProtocolError(t *testing.T) {
+ res := shedToolResult(&limiter.LimitError{
+ Scope: limiter.ScopeServer,
+ Reason: limiter.ReasonQueueFull,
+ Server: "db",
+ Limit: 1,
+ })
+
+ require.NotNil(t, res)
+ assert.True(t, res.IsError)
+ require.Len(t, res.Content, 1)
+ text, ok := res.Content[0].(mcp.TextContent)
+ require.True(t, ok)
+ assert.Contains(t, text.Text, "db")
+ assert.Contains(t, text.Text, limiter.RetryAdvice)
+}
+
+// TestAsShed_ServerUnavailableIsNotAShed keeps the FR-009 semantics separate:
+// a server disabled/removed while a call was queued is "server unavailable",
+// not backpressure.
+func TestAsShed_ServerUnavailableIsNotAShed(t *testing.T) {
+ _, ok := asShed(&limiter.LimitError{Scope: limiter.ScopeServer, Reason: limiter.ReasonServerUnavailable, Server: "db"})
+ assert.False(t, ok)
+
+ _, ok = asShed(errors.New("some upstream failure"))
+ assert.False(t, ok)
+
+ _, ok = asShed(nil)
+ assert.False(t, ok)
+}
+
+// TestShedCapture_PreservesTypedIdentity covers the FR-011 side channel: the
+// MCP handlers can only answer with an isError result, so the typed rejection
+// has to reach CallToolDirect some other way.
+func TestShedCapture_PreservesTypedIdentity(t *testing.T) {
+ ctx, box := withShedCapture(context.Background())
+ assert.Nil(t, box.take(), "nothing captured before a shed")
+
+ limitErr := &limiter.LimitError{Scope: limiter.ScopeServer, Reason: limiter.ReasonQueueTimeout, Server: "db", Limit: 3, RetryAfter: 7 * time.Second}
+ recordShed(ctx, limitErr)
+
+ got := box.take()
+ require.NotNil(t, got)
+ assert.Equal(t, limitErr, got)
+
+ // A dispatch with no capture box installed (the plain MCP transport path)
+ // must not panic.
+ recordShed(context.Background(), limitErr)
+}
+
+// TestShedDispatchError_UnwrapsToLimitError proves the REST handler's
+// errors.As(...) will see the typed rejection through the wrapper.
+func TestShedDispatchError_UnwrapsToLimitError(t *testing.T) {
+ limitErr := &limiter.LimitError{Scope: limiter.ScopeGlobal, Reason: limiter.ReasonQueueFull, Limit: 5, RetryAfter: 2 * time.Second}
+ err := error(&shedDispatchError{limitErr: limitErr, message: shedMessage(limitErr)})
+
+ var target *limiter.LimitError
+ require.True(t, errors.As(err, &target))
+ assert.Equal(t, limiter.ScopeGlobal, target.Scope)
+ assert.Equal(t, 2*time.Second, target.RetryAfter)
+ assert.Contains(t, err.Error(), "proxy-wide")
+}
+
+// TestCodeExecutionToolCaller_IsSubjectToAdmission is the FR-003 coverage proof
+// for the sandboxed code-execution path: it dispatches through the managed
+// client like every other origin, so the same limits bound it — even though it
+// never traverses handleCallToolVariant.
+func TestCodeExecutionToolCaller_IsSubjectToAdmission(t *testing.T) {
+ t.Setenv("CI", "")
+
+ serverCfg := &config.ServerConfig{
+ Name: "db",
+ URL: "http://127.0.0.1:1",
+ Protocol: "http",
+ Enabled: true,
+ MaxConcurrentRequests: shedIntPtr(1),
+ QueueSize: shedIntPtr(0), // no pending capacity: shed at the cap
+ QueueTimeout: shedDurPtr(30 * time.Second),
+ }
+ cfg := &config.Config{Servers: []*config.ServerConfig{serverCfg}}
+
+ um := upstream.NewManager(zap.NewNop(), cfg, nil, secret.NewResolver(), nil)
+ require.NoError(t, um.AddServerConfig("db", serverCfg))
+ client, ok := um.GetClient("db")
+ require.True(t, ok)
+ client.StateManager.TransitionTo(types.StateConnecting)
+ client.StateManager.TransitionTo(types.StateReady)
+
+ lim := um.Limiters().Server("db")
+ require.NotNil(t, lim)
+ release, err := lim.Acquire(context.Background(), time.Time{})
+ require.NoError(t, err)
+ defer release()
+
+ caller := &upstreamToolCaller{
+ upstreamManager: um,
+ logger: zap.NewNop(),
+ executionID: "exec-1",
+ }
+
+ _, callErr := caller.CallTool(context.Background(), "db", "query", map[string]interface{}{})
+ require.Error(t, callErr)
+ assert.True(t, errors.Is(callErr, limiter.ErrQueueFull),
+ "code_execution must be bounded by the same limiter, got: %v", callErr)
+}
+
+// TestCodeExecutionToolCaller_ShedIsAttributedInternally is the P3
+// origin-attribution fix. A script's upstream call inherited the context of
+// whatever surface started the code_execution, so a shed inside a sandboxed
+// script was recorded against the outer MCP client instead of the script.
+func TestCodeExecutionToolCaller_ShedIsAttributedInternally(t *testing.T) {
+ t.Setenv("CI", "")
+
+ serverCfg := &config.ServerConfig{
+ Name: "db",
+ URL: "http://127.0.0.1:1",
+ Protocol: "http",
+ Enabled: true,
+ MaxConcurrentRequests: shedIntPtr(1),
+ QueueSize: shedIntPtr(0),
+ QueueTimeout: shedDurPtr(30 * time.Second),
+ }
+ cfg := &config.Config{Servers: []*config.ServerConfig{serverCfg}}
+
+ um := upstream.NewManager(zap.NewNop(), cfg, nil, secret.NewResolver(), nil)
+ require.NoError(t, um.AddServerConfig("db", serverCfg))
+ client, ok := um.GetClient("db")
+ require.True(t, ok)
+ client.StateManager.TransitionTo(types.StateConnecting)
+ client.StateManager.TransitionTo(types.StateReady)
+
+ sources := make(chan reqcontext.RequestSource, 4)
+ um.SetRejectionObserver(func(ctx context.Context, _ limiter.Rejection) {
+ sources <- reqcontext.GetRequestSource(ctx)
+ })
+
+ lim := um.Limiters().Server("db")
+ require.NotNil(t, lim)
+ release, err := lim.Acquire(context.Background(), time.Time{})
+ require.NoError(t, err)
+ defer release()
+
+ caller := &upstreamToolCaller{
+ upstreamManager: um,
+ logger: zap.NewNop(),
+ executionID: "exec-origin",
+ }
+
+ // The outer surface is MCP; the script's own call must not inherit it.
+ outer := reqcontext.WithRequestSource(context.Background(), reqcontext.SourceMCP)
+ _, callErr := caller.CallTool(outer, "db", "query", map[string]interface{}{})
+ require.Error(t, callErr)
+
+ select {
+ case got := <-sources:
+ assert.Equal(t, reqcontext.SourceInternal, got,
+ "a shed inside a sandboxed script must be attributed to the script, not to the surface that started it")
+ case <-time.After(2 * time.Second):
+ t.Fatal("the shed never reached the rejection observer")
+ }
+}
diff --git a/internal/server/e2e_concurrency_limits_test.go b/internal/server/e2e_concurrency_limits_test.go
new file mode 100644
index 000000000..d0168f95e
--- /dev/null
+++ b/internal/server/e2e_concurrency_limits_test.go
@@ -0,0 +1,292 @@
+package server
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net"
+ "net/http"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/mark3labs/mcp-go/mcp"
+ mcpserver "github.com/mark3labs/mcp-go/server"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.uber.org/zap"
+
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/secret"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/upstream"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/upstream/limiter"
+)
+
+// Spec 093 (GH #955) end-to-end coverage against a REAL slow upstream.
+//
+// The synthetic tests in concurrency_shed_test.go / admission_test.go prove the
+// limiter's own semantics; this file proves the whole chain — config resolution
+// → registry generation → managed-client admission → live streamable-HTTP
+// upstream — behaves as User Story 1 and 2 describe:
+//
+// - US1 independent test: max_concurrent_requests=1, queue_size=1, three
+// concurrent calls ⇒ one runs, one queues then runs, one is shed, and the
+// UPSTREAM never observes two simultaneous requests (SC-001).
+// - US2 scenario 1 / SC-005: the shed is immediate (<100ms) and carries the
+// typed ErrQueueFull identity that the MCP/REST shed seams key off.
+// - FR-003: the sandboxed code-execution origin (upstreamToolCaller, which
+// never traverses handleCallToolVariant) is bounded by the same limiter.
+// - US2 scenario 2 / FR-004: a queued call whose wait exceeds queue_timeout is
+// shed with ErrQueueTimeout.
+
+const slowUpstreamName = "slowsrv"
+
+// slowUpstream is an in-process streamable-HTTP MCP server whose single tool
+// blocks until released, while recording the peak number of simultaneous
+// invocations it observed.
+type slowUpstream struct {
+ inFlight atomic.Int64
+ maxInFlight atomic.Int64
+ entered chan struct{}
+ release chan struct{}
+ addr string
+}
+
+// startSlowUpstream boots the fake upstream and returns it. Every invocation of
+// its "slow_op" tool announces itself on `entered` and then waits for a token on
+// `release`, so the test controls exactly how long a call occupies its slot.
+func startSlowUpstream(t *testing.T) *slowUpstream {
+ t.Helper()
+
+ up := &slowUpstream{
+ entered: make(chan struct{}, 16),
+ release: make(chan struct{}, 16),
+ }
+
+ mcpSrv := mcpserver.NewMCPServer("slow", "1.0.0-test", mcpserver.WithToolCapabilities(true))
+ tool := mcp.Tool{
+ Name: "slow_op",
+ Description: "Blocks until the test releases it",
+ InputSchema: mcp.ToolInputSchema{Type: "object", Properties: map[string]any{}},
+ }
+ mcpSrv.AddTool(tool, func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ cur := up.inFlight.Add(1)
+ for {
+ peak := up.maxInFlight.Load()
+ if cur <= peak || up.maxInFlight.CompareAndSwap(peak, cur) {
+ break
+ }
+ }
+ defer up.inFlight.Add(-1)
+
+ up.entered <- struct{}{}
+ select {
+ case <-up.release:
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ case <-time.After(30 * time.Second):
+ }
+ return mcp.NewToolResultText("done"), nil
+ })
+
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ require.NoError(t, err)
+ up.addr = fmt.Sprintf("http://%s", ln.Addr().String())
+
+ httpSrv := &http.Server{Handler: mcpserver.NewStreamableHTTPServer(mcpSrv), ReadHeaderTimeout: 5 * time.Second}
+ go func() { _ = httpSrv.Serve(ln) }()
+ t.Cleanup(func() { _ = httpSrv.Shutdown(context.Background()) })
+
+ return up
+}
+
+// waitEntered blocks until n calls have reached the upstream tool body.
+func (u *slowUpstream) waitEntered(t *testing.T, n int) {
+ t.Helper()
+ for i := 0; i < n; i++ {
+ select {
+ case <-u.entered:
+ case <-time.After(15 * time.Second):
+ t.Fatalf("timed out waiting for upstream invocation %d/%d", i+1, n)
+ }
+ }
+}
+
+// releaseN unblocks n in-flight upstream calls.
+func (u *slowUpstream) releaseN(n int) {
+ for i := 0; i < n; i++ {
+ u.release <- struct{}{}
+ }
+}
+
+// newLimitedManager wires the slow upstream into a real upstream.Manager with
+// the given per-server concurrency limits and waits for it to connect.
+func newLimitedManager(t *testing.T, up *slowUpstream, maxConcurrent, queueSize int, queueTimeout time.Duration) *upstream.Manager {
+ t.Helper()
+
+ maxCopy, queueCopy := maxConcurrent, queueSize
+ timeoutCopy := config.Duration(queueTimeout)
+ serverCfg := &config.ServerConfig{
+ Name: slowUpstreamName,
+ URL: up.addr,
+ Protocol: "streamable-http",
+ Enabled: true,
+ MaxConcurrentRequests: &maxCopy,
+ QueueSize: &queueCopy,
+ QueueTimeout: &timeoutCopy,
+ }
+ cfg := &config.Config{
+ Servers: []*config.ServerConfig{serverCfg},
+ CallToolTimeout: config.Duration(60 * time.Second),
+ ToolsLimit: 15,
+ }
+
+ // Validation is part of the contract under test: these limits must be a
+ // legal configuration (FR-023).
+ require.Empty(t, cfg.ValidateDetailed(), "concurrency limits must pass config validation")
+
+ um := upstream.NewManager(zap.NewNop(), cfg, nil, secret.NewResolver(), nil)
+ t.Cleanup(func() {
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ _ = um.ShutdownAll(shutdownCtx)
+ })
+
+ require.NoError(t, um.AddServerConfig(slowUpstreamName, serverCfg))
+ require.NoError(t, um.ConnectAll(context.Background()))
+ require.Eventually(t, func() bool {
+ client, ok := um.GetClient(slowUpstreamName)
+ return ok && client.IsConnected()
+ }, 15*time.Second, 50*time.Millisecond, "slow upstream must connect")
+
+ lim := um.Limiters().Server(slowUpstreamName)
+ require.NotNil(t, lim, "a limited server must own a limiter instance")
+ require.Equal(t, maxConcurrent, lim.Limits().Max)
+
+ return um
+}
+
+// callResult carries the outcome of one asynchronous dispatch.
+type callResult struct {
+ err error
+}
+
+// dispatchAsync fires manager.CallTool — the single entry point every MCP,
+// REST and direct-routing origin funnels through — on its own goroutine.
+func dispatchAsync(um *upstream.Manager, wg *sync.WaitGroup, out *callResult) {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ _, err := um.CallTool(context.Background(), slowUpstreamName+":slow_op", map[string]interface{}{})
+ out.err = err
+ }()
+}
+
+// TestE2EConcurrencyLimits_QueueThenShed is the User Story 1 + 2 independent
+// test against a live upstream (FR-001..FR-004, FR-010, SC-001, SC-005).
+func TestE2EConcurrencyLimits_QueueThenShed(t *testing.T) {
+ t.Setenv("CI", "")
+ t.Setenv("MCPPROXY_DISABLE_OAUTH", "true")
+
+ up := startSlowUpstream(t)
+ um := newLimitedManager(t, up, 1, 1, 10*time.Second)
+ lim := um.Limiters().Server(slowUpstreamName)
+
+ var wg sync.WaitGroup
+ var first, second callResult
+
+ // Call 1 takes the only slot and blocks inside the upstream.
+ dispatchAsync(um, &wg, &first)
+ up.waitEntered(t, 1)
+ require.Equal(t, 1, lim.Stats().Running, "call 1 must hold the single slot")
+
+ // Call 2 finds the cap saturated and parks in the one-deep FIFO queue.
+ dispatchAsync(um, &wg, &second)
+ require.Eventually(t, func() bool {
+ return lim.Stats().Queued == 1
+ }, 5*time.Second, 10*time.Millisecond, "call 2 must be queued, not running")
+ assert.Equal(t, int64(1), up.inFlight.Load(), "the upstream must still see exactly one request")
+
+ // Call 3 arrives at a full queue: shed IMMEDIATELY (SC-005 <100ms) with the
+ // typed identity the shed seams key off (FR-010/FR-011).
+ shedStart := time.Now()
+ _, thirdErr := um.CallTool(context.Background(), slowUpstreamName+":slow_op", map[string]interface{}{})
+ shedElapsed := time.Since(shedStart)
+ require.Error(t, thirdErr, "call 3 must be shed, not admitted")
+ require.True(t, errors.Is(thirdErr, limiter.ErrQueueFull), "call 3 must be a queue_full shed, got: %v", thirdErr)
+ assert.Less(t, shedElapsed, 100*time.Millisecond, "a full-queue shed must not wait (SC-005)")
+
+ var limitErr *limiter.LimitError
+ require.True(t, errors.As(thirdErr, &limitErr))
+ assert.Equal(t, limiter.ScopeServer, limitErr.Scope)
+ assert.Equal(t, slowUpstreamName, limitErr.Server)
+ assert.Contains(t, limitErr.UserMessage(), limiter.RetryAdvice)
+
+ // FR-003: the sandboxed code-execution origin never reaches
+ // handleCallToolVariant, yet the same limiter bounds it.
+ caller := &upstreamToolCaller{
+ upstreamManager: um,
+ logger: zap.NewNop(),
+ executionID: "exec-concurrency-e2e",
+ }
+ _, codeExecErr := caller.CallTool(context.Background(), slowUpstreamName, "slow_op", map[string]interface{}{})
+ require.Error(t, codeExecErr, "code_execution must not bypass the limiter")
+ assert.True(t, errors.Is(codeExecErr, limiter.ErrQueueFull),
+ "code_execution must be shed by the same limiter, got: %v", codeExecErr)
+
+ // Drain: releasing call 1 hands the slot to the queued call 2, which then
+ // runs against the upstream and succeeds.
+ up.releaseN(1)
+ up.waitEntered(t, 1)
+ up.releaseN(1)
+ wg.Wait()
+
+ require.NoError(t, first.err, "call 1 must succeed")
+ require.NoError(t, second.err, "call 2 must queue and then succeed")
+ assert.Equal(t, int64(1), up.maxInFlight.Load(),
+ "the upstream must never observe more than max_concurrent_requests simultaneous calls (SC-001)")
+ assert.Equal(t, 0, lim.Stats().Running, "every slot must be released")
+ assert.Equal(t, 0, lim.Stats().Queued, "the queue must be empty")
+}
+
+// TestE2EConcurrencyLimits_QueueTimeoutSheds covers US2 scenario 2 / FR-004: a
+// queued call that waits past the configured queue_timeout is shed with the
+// timeout-flavoured typed error rather than hanging.
+func TestE2EConcurrencyLimits_QueueTimeoutSheds(t *testing.T) {
+ t.Setenv("CI", "")
+ t.Setenv("MCPPROXY_DISABLE_OAUTH", "true")
+
+ up := startSlowUpstream(t)
+ um := newLimitedManager(t, up, 1, 1, 250*time.Millisecond)
+ lim := um.Limiters().Server(slowUpstreamName)
+
+ var wg sync.WaitGroup
+ var first callResult
+
+ dispatchAsync(um, &wg, &first)
+ up.waitEntered(t, 1)
+
+ // This call queues behind the blocked one and must be shed once its
+ // absolute queue deadline passes — it must not wait for the upstream.
+ queueStart := time.Now()
+ _, queuedErr := um.CallTool(context.Background(), slowUpstreamName+":slow_op", map[string]interface{}{})
+ queueElapsed := time.Since(queueStart)
+
+ require.Error(t, queuedErr)
+ require.True(t, errors.Is(queuedErr, limiter.ErrQueueTimeout),
+ "a queued call past its deadline must be a queue_timeout shed, got: %v", queuedErr)
+ assert.GreaterOrEqual(t, queueElapsed, 200*time.Millisecond, "the call must actually have waited in the queue")
+ assert.Less(t, queueElapsed, 10*time.Second, "the call must not wait for the upstream to finish")
+
+ var limitErr *limiter.LimitError
+ require.True(t, errors.As(queuedErr, &limitErr))
+ assert.Equal(t, limiter.ReasonQueueTimeout, limitErr.Reason)
+ assert.Equal(t, 250*time.Millisecond, limitErr.RetryAfter, "Retry-After derives from the shedding scope's queue_timeout")
+
+ up.releaseN(1)
+ wg.Wait()
+ require.NoError(t, first.err)
+ assert.Equal(t, int64(1), up.maxInFlight.Load())
+ assert.Equal(t, 0, lim.Stats().Queued)
+}
diff --git a/internal/server/mcp.go b/internal/server/mcp.go
index e1f956c11..576af7b31 100644
--- a/internal/server/mcp.go
+++ b/internal/server/mcp.go
@@ -2172,6 +2172,36 @@ func (p *MCPProxyServer) handleCallToolVariant(ctx context.Context, request mcp.
}
if err != nil {
+ // Spec 093 FR-010/FR-011: a concurrency-limiter shed is not an upstream
+ // failure. It answers with a retry-friendly isError result (never a
+ // protocol error), preserves its typed identity for the REST 429 mapping,
+ // and is NOT recorded here — the limiter's origin-independent seam already
+ // wrote the "rejected" activity record (FR-012), so emitting an "error"
+ // record too would double-count the same call.
+ if limitErr, isShed := asShed(err); isShed {
+ shedMsg := shedMessage(limitErr)
+ toolCallRecord.Error = shedMsg
+ if storeErr := p.storage.RecordToolCall(toolCallRecord); storeErr != nil {
+ p.logger.Warn("Failed to record shed tool call", zap.Error(storeErr))
+ }
+ p.logger.Info("Tool call shed by concurrency limiter",
+ zap.String("server", serverName),
+ zap.String("tool", actualToolName),
+ zap.String("scope", string(limitErr.Scope)),
+ zap.String("reason", string(limitErr.Reason)),
+ zap.Int("limit", limitErr.Limit))
+ recordShed(ctx, limitErr)
+
+ var shedIntentMap map[string]interface{}
+ if intent != nil {
+ shedIntentMap = intent.ToMap()
+ }
+ internalToolName := "call_tool_" + intent.OperationType
+ p.emitActivityInternalToolCall(internalToolName, serverName, actualToolName, toolVariant, sessionID, requestID, storage.ActivityStatusRejected, shedMsg, time.Since(internalStartTime).Milliseconds(), activityArgs, nil, shedIntentMap, "")
+
+ return shedToolResult(limitErr), nil
+ }
+
// Record error in tool call history
toolCallRecord.Error = err.Error()
@@ -2612,6 +2642,19 @@ func (p *MCPProxyServer) handleCallTool(ctx context.Context, request mcp.CallToo
contentTrust := contracts.ContentTrustForTool(toolCallRecord.Annotations)
if err != nil {
+ // Spec 093 FR-010: same shed semantics on the legacy path — a retry-
+ // friendly isError result, typed identity preserved for REST, and no
+ // duplicate activity record (the limiter seam already wrote "rejected").
+ if limitErr, isShed := asShed(err); isShed {
+ shedMsg := shedMessage(limitErr)
+ toolCallRecord.Error = shedMsg
+ if storeErr := p.storage.RecordToolCall(toolCallRecord); storeErr != nil {
+ p.logger.Warn("Failed to record shed tool call", zap.Error(storeErr))
+ }
+ recordShed(ctx, limitErr)
+ return shedToolResult(limitErr), nil
+ }
+
// Record error in tool call history
toolCallRecord.Error = err.Error()
@@ -5356,6 +5399,13 @@ func (p *MCPProxyServer) monitorConnectionStatus(ctx context.Context, serverName
func (p *MCPProxyServer) CallToolDirect(ctx context.Context, request mcp.CallToolRequest) (interface{}, error) {
toolName := request.Params.Name
+ // Spec 093 FR-011: the REST surface must answer a concurrency-limiter shed
+ // with 429 + Retry-After, but the handlers below can only answer with an
+ // isError RESULT (the MCP contract). Install a capture box so the typed
+ // *limiter.LimitError survives to the HTTP layer instead of being flattened
+ // into a string by the IsError branch at the bottom of this function.
+ ctx, shed := withShedCapture(ctx)
+
// Route to the appropriate handler based on tool name
var result *mcp.CallToolResult
var err error
@@ -5396,6 +5446,11 @@ func (p *MCPProxyServer) CallToolDirect(ctx context.Context, request mcp.CallToo
// Extract the actual result content from the MCP response
if result.IsError {
+ // A shed keeps its typed identity so the HTTP layer can map it to 429 +
+ // Retry-After (FR-011). The message is still the agent-readable one.
+ if limitErr := shed.take(); limitErr != nil {
+ return nil, &shedDispatchError{limitErr: limitErr, message: shedMessage(limitErr)}
+ }
if len(result.Content) > 0 {
if textContent, ok := result.Content[0].(mcp.TextContent); ok {
return nil, fmt.Errorf("%s", textContent.Text)
diff --git a/internal/server/mcp_code_execution.go b/internal/server/mcp_code_execution.go
index eaf480e44..b6145d3a8 100644
--- a/internal/server/mcp_code_execution.go
+++ b/internal/server/mcp_code_execution.go
@@ -11,6 +11,7 @@ import (
"github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/jsruntime"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/profile"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/reqcontext"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/storage"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/upstream"
@@ -444,6 +445,13 @@ type upstreamToolCaller struct {
func (u *upstreamToolCaller) CallTool(ctx context.Context, serverName, toolName string, args map[string]interface{}) (interface{}, error) {
startTime := time.Now()
+ // Spec 093 FR-012: a call issued by a sandboxed script is an INTERNAL origin,
+ // whatever surface asked for the code_execution around it. Without this the
+ // context still carries the outer MCP (or REST) source, so a shed inside a
+ // script was attributed to the client that started the script rather than to
+ // the script itself.
+ ctx = reqcontext.WithRequestSource(ctx, reqcontext.SourceInternal)
+
u.logger.Debug("calling upstream tool from JavaScript",
zap.String("execution_id", u.executionID),
zap.String("server", serverName),
diff --git a/internal/server/mcp_routing.go b/internal/server/mcp_routing.go
index 4031980e2..d51c1a749 100644
--- a/internal/server/mcp_routing.go
+++ b/internal/server/mcp_routing.go
@@ -221,6 +221,14 @@ func (p *MCPProxyServer) makeDirectModeHandler(serverName, toolName string, anno
directContentTrust := contracts.ContentTrustForTool(annotations)
if err != nil {
+ // Spec 093 FR-010: direct-routing mode sheds like every other
+ // dispatch path — retry-friendly isError result, typed identity kept
+ // for the REST 429 mapping, and no duplicate activity record (the
+ // limiter seam already wrote the "rejected" one).
+ if limitErr, isShed := asShed(err); isShed {
+ recordShed(ctx, limitErr)
+ return shedToolResult(limitErr), nil
+ }
// Emit error activity
p.emitActivityToolCallCompleted(serverName, toolName, sessionID, requestID, "mcp", "error", err.Error(), durationMs, enrichedArgs, "", false, "", nil, directContentTrust, "", 0, 0, "", nil)
return mcp.NewToolResultError(fmt.Sprintf("Error calling %s:%s: %v", serverName, toolName, err)), nil
diff --git a/internal/server/observability_bridge.go b/internal/server/observability_bridge.go
index 073e74284..5dfe269c3 100644
--- a/internal/server/observability_bridge.go
+++ b/internal/server/observability_bridge.go
@@ -2,6 +2,7 @@ package server
import (
"context"
+ "time"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/observability"
@@ -19,11 +20,27 @@ func (s *Server) runMetricsBridge(ctx context.Context, mm *observability.Metrics
}
events := s.runtime.SubscribeEvents()
defer s.runtime.UnsubscribeEvents(events)
+
+ // Spec 093 FR-013: rejections are counted at the rejection site, not from
+ // the (lossy) event bus.
+ s.runtime.SetRejectionMetricSink(recordRejectionMetric(mm))
+ defer s.runtime.SetRejectionMetricSink(nil)
+
s.logger.Info("Observability metrics bridge started")
+
+ // Spec 093 FR-013: queue depth is a level, not an event, so it is sampled
+ // rather than written on every acquire/release — the gauges exist to show
+ // SUSTAINED saturation, and per-call writes would drag a metrics mutex onto
+ // the tool-call hot path.
+ depthTicker := time.NewTicker(concurrencyDepthSampleInterval)
+ defer depthTicker.Stop()
+
for {
select {
case <-ctx.Done():
return
+ case <-depthTicker.C:
+ s.sampleConcurrencyDepth(mm)
case evt, ok := <-events:
if !ok {
return
@@ -33,6 +50,23 @@ func (s *Server) runMetricsBridge(ctx context.Context, mm *observability.Metrics
}
}
+// concurrencyDepthSampleInterval is how often the limiter gauges are refreshed.
+const concurrencyDepthSampleInterval = 10 * time.Second
+
+// sampleConcurrencyDepth publishes the live occupancy of every limiter scope.
+// Series are reset first so a removed server stops reporting a stale depth.
+func (s *Server) sampleConcurrencyDepth(mm *observability.MetricsManager) {
+ if s.runtime == nil {
+ return
+ }
+ global, servers := s.runtime.ConcurrencyStats()
+ mm.ResetConcurrencyDepth()
+ mm.SetConcurrencyDepth("global", "", global.Running, global.Queued)
+ for name, st := range servers {
+ mm.SetConcurrencyDepth("server", name, st.Running, st.Queued)
+ }
+}
+
// applyMetricEvent translates a single runtime event into metric updates. It is
// defensive against malformed payloads (best-effort observability must never
// panic the daemon).
@@ -60,5 +94,24 @@ func applyMetricEvent(mm *observability.MetricsManager, evt runtime.Event) {
action = "unknown"
}
mm.RecordQuarantineEvent("tool", action)
+
+ }
+}
+
+// recordRejectionMetric counts one shed. It is installed as the runtime's
+// synchronous rejection sink rather than driven off the event bus (spec 093
+// FR-013): publishEvent drops events for a subscriber that falls behind, so a
+// burst of sheds — the only load where the counter is interesting — is exactly
+// when bus-driven counting would lose increments. The sink still sees every
+// origin, because it hangs off the limiter's own observer.
+func recordRejectionMetric(mm *observability.MetricsManager) runtime.RejectionMetricSink {
+ return func(server, reason, scope string) {
+ if reason == "" {
+ reason = "unknown"
+ }
+ if scope == "" {
+ scope = "unknown"
+ }
+ mm.RecordToolCallRejected(server, reason, scope)
}
}
diff --git a/internal/server/observability_bridge_test.go b/internal/server/observability_bridge_test.go
index 16513ead9..015cb9e5b 100644
--- a/internal/server/observability_bridge_test.go
+++ b/internal/server/observability_bridge_test.go
@@ -111,3 +111,31 @@ func TestApplyMetricEvent_IgnoresUnrelatedAndMalformed(t *testing.T) {
applyMetricEvent(mm, runtime.Event{Type: runtime.EventTypeServersChanged, Payload: map[string]any{"stats": "not-a-struct"}})
applyMetricEvent(mm, runtime.Event{Type: runtime.EventTypeActivityQuarantineChange, Payload: nil})
}
+
+// TestRecordRejectionMetric_CountsSynchronously is the FR-013 counting
+// contract. The counter is incremented by the limiter's own observer, not by an
+// event-bus subscriber: publishEvent drops events once a subscriber's channel
+// fills, which under a burst of sheds would lose exactly the increments that
+// make the burst visible.
+func TestRecordRejectionMetric_CountsSynchronously(t *testing.T) {
+ mm := observability.NewMetricsManager(zap.NewNop().Sugar())
+ sink := recordRejectionMetric(mm)
+
+ sink("analytics-db", "queue_full", "server")
+ sink("analytics-db", "queue_full", "server")
+ sink("analytics-db", "", "") // malformed labels must still count
+
+ reg := mm.Registry()
+ assert.InDelta(t, 2.0, metricValue(t, reg, "mcpproxy_tool_calls_rejected_total",
+ map[string]string{"server": "analytics-db", "reason": "queue_full", "scope": "server"}), 1e-9)
+ assert.InDelta(t, 1.0, metricValue(t, reg, "mcpproxy_tool_calls_rejected_total",
+ map[string]string{"server": "analytics-db", "reason": "unknown", "scope": "unknown"}), 1e-9)
+
+ // The bus must NOT also count the shed, or every rejection is counted twice.
+ applyMetricEvent(mm, runtime.Event{
+ Type: runtime.EventTypeActivityToolCallRejected,
+ Payload: map[string]any{"server_name": "analytics-db", "reason": "queue_full", "scope": "server"},
+ })
+ assert.InDelta(t, 2.0, metricValue(t, reg, "mcpproxy_tool_calls_rejected_total",
+ map[string]string{"server": "analytics-db", "reason": "queue_full", "scope": "server"}), 1e-9)
+}
diff --git a/internal/server/server.go b/internal/server/server.go
index 062d3894d..71342eb46 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -3086,9 +3086,11 @@ func (s *Server) GetServerToolCalls(serverName string, limit int) ([]*contracts.
return s.runtime.GetServerToolCalls(serverName, limit)
}
-// ReplayToolCall replays a tool call with modified arguments
-func (s *Server) ReplayToolCall(id string, arguments map[string]interface{}) (*contracts.ToolCallRecord, error) {
- return s.runtime.ReplayToolCall(id, arguments)
+// ReplayToolCall replays a tool call with modified arguments. ctx is the
+// caller's request context: it governs the concurrency-limiter queue wait as
+// well as the upstream call (spec 093 FR-005).
+func (s *Server) ReplayToolCall(ctx context.Context, id string, arguments map[string]interface{}) (*contracts.ToolCallRecord, error) {
+ return s.runtime.ReplayToolCall(ctx, id, arguments)
}
// GetToolCallsBySession retrieves tool calls filtered by session ID
diff --git a/internal/storage/activity_models.go b/internal/storage/activity_models.go
index d4c79b16b..b31ad85a2 100644
--- a/internal/storage/activity_models.go
+++ b/internal/storage/activity_models.go
@@ -54,6 +54,47 @@ var ValidActivityTypes = []string{
string(ActivityTypeCredentialBroker),
}
+// Activity status vocabulary. Activity status is a CLOSED vocabulary: every
+// consumer (filters, summaries, usage aggregation, exports, Web UI badges)
+// switches on these values, so a new status has to be threaded through all of
+// them — see spec 093 FR-012.
+const (
+ // ActivityStatusSuccess is a call the upstream answered normally.
+ ActivityStatusSuccess = "success"
+ // ActivityStatusError is a call that failed (transport, upstream error, or
+ // an isError:true answer).
+ ActivityStatusError = "error"
+ // ActivityStatusBlocked is a call a policy prevented from running.
+ ActivityStatusBlocked = "blocked"
+ // ActivityStatusRejected is a call shed by a concurrency limiter before it
+ // ever reached the upstream (spec 093). Distinct from "error" on purpose:
+ // nothing went wrong upstream, the proxy applied backpressure. The record's
+ // metadata carries rejection_reason (queue_full | queue_timeout) and
+ // rejection_scope (server | global).
+ ActivityStatusRejected = "rejected"
+)
+
+// ValidActivityStatuses is the closed status vocabulary, for filter validation
+// and API documentation.
+var ValidActivityStatuses = []string{
+ ActivityStatusSuccess,
+ ActivityStatusError,
+ ActivityStatusBlocked,
+ ActivityStatusRejected,
+}
+
+// Metadata keys carried by an ActivityStatusRejected record (spec 093 FR-012).
+const (
+ // MetadataKeyRejectionReason is "queue_full" or "queue_timeout".
+ MetadataKeyRejectionReason = "rejection_reason"
+ // MetadataKeyRejectionScope is "server" or "global".
+ MetadataKeyRejectionScope = "rejection_scope"
+ // MetadataKeyRejectionLimit is the cap that was in force in that scope.
+ MetadataKeyRejectionLimit = "rejection_limit"
+ // MetadataKeyRejectionRetryAfterMs is the Retry-After hint in milliseconds.
+ MetadataKeyRejectionRetryAfterMs = "rejection_retry_after_ms"
+)
+
// ActivitySource indicates how the activity was triggered
type ActivitySource string
@@ -78,7 +119,7 @@ type ActivityRecord struct {
Arguments map[string]interface{} `json:"arguments,omitempty"` // Tool call arguments
Response string `json:"response,omitempty"` // Tool response (potentially truncated)
ResponseTruncated bool `json:"response_truncated,omitempty"` // True if response was truncated
- Status string `json:"status"` // Result status: "success", "error", "blocked"
+ Status string `json:"status"` // Result status: "success", "error", "blocked", "rejected"
ErrorMessage string `json:"error_message,omitempty"` // Error details if status is "error"
DurationMs int64 `json:"duration_ms,omitempty"` // Execution duration in milliseconds
Timestamp time.Time `json:"timestamp"` // When activity occurred
@@ -124,7 +165,7 @@ type ActivityFilter struct {
Server string // Filter by server name
Tool string // Filter by tool name
SessionID string // Filter by MCP transport session
- Status string // Filter by status (success/error/blocked)
+ Status string // Filter by status (success/error/blocked/rejected)
StartTime time.Time // Activities after this time
EndTime time.Time // Activities before this time
Limit int // Max records to return (default 50, max 100)
@@ -146,9 +187,11 @@ type ActivityFilter struct {
AgentName string // Filter by agent token name in metadata
AuthType string // Filter by auth type: "admin" or "agent"
- // ExcludeCallToolSuccess filters out successful call_tool_* internal tool calls.
- // These appear as duplicates since the actual upstream tool call is also logged.
- // Failed call_tool_* calls are still shown (no corresponding tool_call entry).
+ // ExcludeCallToolSuccess filters out call_tool_* internal tool calls that are
+ // already represented by a tool_call record: successful ones (the upstream
+ // call is logged) and rejected ones (the concurrency limiter logged the shed,
+ // spec 093). Failed call_tool_* calls are still shown — they have no
+ // corresponding tool_call entry.
// Default: true (to avoid duplicate entries in UI/CLI)
ExcludeCallToolSuccess bool
}
@@ -252,12 +295,17 @@ func (f *ActivityFilter) Matches(record *ActivityRecord) bool {
return false
}
- // Exclude successful call_tool_* internal tool calls to avoid duplicates
- // These have a corresponding tool_call entry that shows the actual upstream call.
- // Failed call_tool_* calls are shown since they have no corresponding tool_call.
+ // Exclude call_tool_* internal tool calls that are already represented by a
+ // canonical tool_call entry, so one dispatch is never counted twice.
+ //
+ // A SUCCESSFUL call_tool_* has the upstream's own tool_call record; a
+ // REJECTED one has the record the concurrency limiter wrote at the shed
+ // (spec 093 FR-012), which every origin produces — the variant handler
+ // merely adds a second, MCP-flavoured row on top of it. Failed call_tool_*
+ // calls stay visible: they have no corresponding tool_call.
if f.ExcludeCallToolSuccess {
if record.Type == ActivityTypeInternalToolCall &&
- record.Status == "success" &&
+ (record.Status == ActivityStatusSuccess || record.Status == ActivityStatusRejected) &&
strings.HasPrefix(record.ToolName, "call_tool_") {
return false
}
diff --git a/internal/storage/activity_test.go b/internal/storage/activity_test.go
index acea0835c..0c6b3fe92 100644
--- a/internal/storage/activity_test.go
+++ b/internal/storage/activity_test.go
@@ -963,3 +963,56 @@ func TestAggregateToolUsage(t *testing.T) {
assert.False(t, ok, "never-used tools must be absent from the map")
})
}
+
+// TestActivityFilter_OneRejectedRowPerShed is the spec-093 de-duplication
+// contract. A shed dispatched through an MCP tool-call variant writes two
+// records: the canonical tool_call rejection the limiter logs for EVERY origin,
+// and an internal_tool_call rejection the variant handler adds. The default
+// filter used by listings and by the activity summary must surface exactly one
+// of them, or a saturated proxy reads as twice as saturated as it is.
+func TestActivityFilter_OneRejectedRowPerShed(t *testing.T) {
+ ts := time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC)
+ canonical := &ActivityRecord{
+ Type: ActivityTypeToolCall,
+ ServerName: "analytics-db",
+ ToolName: "query",
+ Status: ActivityStatusRejected,
+ Timestamp: ts,
+ }
+ variantEcho := &ActivityRecord{
+ Type: ActivityTypeInternalToolCall,
+ ServerName: "analytics-db",
+ ToolName: "call_tool_read",
+ Status: ActivityStatusRejected,
+ Timestamp: ts,
+ }
+ failedVariant := &ActivityRecord{
+ Type: ActivityTypeInternalToolCall,
+ ServerName: "analytics-db",
+ ToolName: "call_tool_read",
+ Status: ActivityStatusError,
+ Timestamp: ts,
+ }
+
+ def := DefaultActivityFilter()
+ assert.True(t, def.Matches(canonical), "the limiter's rejected row is the canonical one")
+ assert.False(t, def.Matches(variantEcho),
+ "the variant handler's rejected echo duplicates the canonical row and must be hidden by default")
+ assert.True(t, def.Matches(failedVariant),
+ "a failed call_tool_* has no corresponding tool_call and must stay visible")
+
+ // The code_execution and replay origins never reach the variant handler, so
+ // their shed produces the canonical row only — still exactly one.
+ rejected := 0
+ for _, rec := range []*ActivityRecord{canonical, variantEcho} {
+ if def.Matches(rec) && rec.Status == ActivityStatusRejected {
+ rejected++
+ }
+ }
+ assert.Equal(t, 1, rejected, "exactly one rejected row per shed")
+
+ // Opting in still shows both, so nothing is lost from storage.
+ all := DefaultActivityFilter()
+ all.ExcludeCallToolSuccess = false
+ assert.True(t, all.Matches(variantEcho), "include_call_tool=true must still show the variant echo")
+}
diff --git a/internal/storage/async_ops.go b/internal/storage/async_ops.go
index ac4cc7108..a102d7854 100644
--- a/internal/storage/async_ops.go
+++ b/internal/storage/async_ops.go
@@ -207,6 +207,9 @@ func (am *AsyncManager) saveServerSync(serverConfig *config.ServerConfig) error
ToolDiscoveryInterval: serverConfig.ToolDiscoveryInterval,
InitTimeout: serverConfig.InitTimeout,
ToonOutput: serverConfig.ToonOutput,
+ MaxConcurrentRequests: serverConfig.MaxConcurrentRequests,
+ QueueSize: serverConfig.QueueSize,
+ QueueTimeout: serverConfig.QueueTimeout,
}
return am.db.SaveUpstream(record)
}
diff --git a/internal/storage/async_ops_test.go b/internal/storage/async_ops_test.go
index 807b441c9..64157502b 100644
--- a/internal/storage/async_ops_test.go
+++ b/internal/storage/async_ops_test.go
@@ -361,6 +361,12 @@ func TestSaveServerSyncFieldCoverage(t *testing.T) {
// UpstreamRecord so a REST/UI-set override survives a restart and a
// SaveConfiguration rebuild of the JSON server list.
"ToonOutput": true,
+ // Spec 093: per-server concurrency-limit overrides; round-tripped
+ // through UpstreamRecord so a REST/UI-set limit survives a restart and a
+ // SaveConfiguration rebuild of the JSON server list.
+ "MaxConcurrentRequests": true,
+ "QueueSize": true,
+ "QueueTimeout": true,
// Issue #937: unexported parse-time bit recording whether the JSON
// document carried a "quarantined" key. It describes the DOCUMENT that
// was parsed, not the server, so it is deliberately NOT persisted — a
diff --git a/internal/storage/manager.go b/internal/storage/manager.go
index 77c2d58c2..975935704 100644
--- a/internal/storage/manager.go
+++ b/internal/storage/manager.go
@@ -133,6 +133,9 @@ func (m *Manager) SaveUpstreamServer(serverConfig *config.ServerConfig) error {
ToolDiscoveryInterval: serverConfig.ToolDiscoveryInterval,
InitTimeout: serverConfig.InitTimeout,
ToonOutput: serverConfig.ToonOutput,
+ MaxConcurrentRequests: serverConfig.MaxConcurrentRequests,
+ QueueSize: serverConfig.QueueSize,
+ QueueTimeout: serverConfig.QueueTimeout,
}
return m.db.SaveUpstream(record)
@@ -176,6 +179,9 @@ func (m *Manager) GetUpstreamServer(name string) (*config.ServerConfig, error) {
ToolDiscoveryInterval: record.ToolDiscoveryInterval,
InitTimeout: record.InitTimeout,
ToonOutput: record.ToonOutput,
+ MaxConcurrentRequests: record.MaxConcurrentRequests,
+ QueueSize: record.QueueSize,
+ QueueTimeout: record.QueueTimeout,
}, nil
}
@@ -219,6 +225,9 @@ func (m *Manager) ListUpstreamServers() ([]*config.ServerConfig, error) {
ToolDiscoveryInterval: record.ToolDiscoveryInterval,
InitTimeout: record.InitTimeout,
ToonOutput: record.ToonOutput,
+ MaxConcurrentRequests: record.MaxConcurrentRequests,
+ QueueSize: record.QueueSize,
+ QueueTimeout: record.QueueTimeout,
})
}
diff --git a/internal/storage/models.go b/internal/storage/models.go
index 66ec1d7d7..212a68ae0 100644
--- a/internal/storage/models.go
+++ b/internal/storage/models.go
@@ -170,6 +170,14 @@ type UpstreamRecord struct {
// persisted so the override survives a restart and a SaveConfiguration
// rebuild of the JSON server list.
ToonOutput string `json:"toon_output,omitempty"`
+ // Spec 093: per-server concurrency-limit overrides, persisted for the same
+ // reason as the interval overrides above — SaveConfiguration rebuilds the
+ // JSON server list from these records, so a REST/UI-set limit would be
+ // wiped on the next save without them. Tri-state: nil = inherit the
+ // per-server default set, 0 = opt out, positive = override.
+ MaxConcurrentRequests *int `json:"max_concurrent_requests,omitempty"`
+ QueueSize *int `json:"queue_size,omitempty"`
+ QueueTimeout *config.Duration `json:"queue_timeout,omitempty"`
}
// ToolStatRecord represents tool usage statistics
diff --git a/internal/tui/views.go b/internal/tui/views.go
index af4c19ea6..14caf63d0 100644
--- a/internal/tui/views.go
+++ b/internal/tui/views.go
@@ -247,6 +247,10 @@ func renderActivity(m model, maxHeight int) string {
statusStyle = unhealthyStyle
case "blocked":
statusStyle = degradedStyle
+ case "rejected":
+ // Spec 093: shed by a concurrency limit — backpressure, so it
+ // renders degraded rather than as an upstream failure.
+ statusStyle = degradedStyle
default:
statusStyle = BaseStyle
}
diff --git a/internal/upstream/concurrency.go b/internal/upstream/concurrency.go
new file mode 100644
index 000000000..31d06225a
--- /dev/null
+++ b/internal/upstream/concurrency.go
@@ -0,0 +1,150 @@
+package upstream
+
+import (
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/upstream/limiter"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/upstream/managed"
+)
+
+// toLimiterLimits converts a resolved config scope into the limiter's shape.
+func toLimiterLimits(r config.ResolvedConcurrency) limiter.Limits {
+ return limiter.Limits{
+ Max: r.MaxConcurrentRequests,
+ QueueSize: r.QueueSize,
+ QueueTimeout: r.QueueTimeout,
+ }
+}
+
+// limiterEligible reports whether a server should own a limiter instance at all.
+// A disabled or quarantined server must not: FR-009 requires its queued calls to
+// fail promptly with the server-unavailable semantics rather than sit until the
+// queue deadline, which is exactly what retiring its instance does.
+func limiterEligible(sc *config.ServerConfig) bool {
+ return sc != nil && sc.Name != "" && sc.Enabled && !sc.Quarantined
+}
+
+// Limiters exposes the live limiter registry (metrics surface, tests).
+func (m *Manager) Limiters() *limiter.Registry {
+ if m == nil {
+ return nil
+ }
+ return m.limiters
+}
+
+// SetRejectionObserver installs the origin-independent shed seam (FR-012): the
+// runtime passes a callback that turns a rejection into a "rejected" activity
+// record and a rejection metric, regardless of which surface originated the
+// call. Existing clients are re-wired immediately.
+func (m *Manager) SetRejectionObserver(observe limiter.Observer) {
+ if m == nil {
+ return
+ }
+ if observe == nil {
+ m.rejectObserver.Store(nil)
+ } else {
+ m.rejectObserver.Store(&observe)
+ }
+
+ m.mu.RLock()
+ clients := make([]*managed.Client, 0, len(m.clients))
+ for _, client := range m.clients {
+ if client != nil {
+ clients = append(clients, client)
+ }
+ }
+ m.mu.RUnlock()
+
+ for _, client := range clients {
+ client.SetAdmissionControl(m.limiters, m.currentRejectObserver())
+ }
+}
+
+// currentRejectObserver returns the installed observer, or nil.
+func (m *Manager) currentRejectObserver() limiter.Observer {
+ if m == nil {
+ return nil
+ }
+ if p := m.rejectObserver.Load(); p != nil {
+ return *p
+ }
+ return nil
+}
+
+// ConcurrencyStats reports the current occupancy of the global aggregate
+// limiter and of every live per-server limiter (FR-013: queue depth).
+func (m *Manager) ConcurrencyStats() (global limiter.Stats, servers map[string]limiter.Stats) {
+ if m == nil || m.limiters == nil {
+ return limiter.Stats{}, nil
+ }
+ return m.limiters.Global().Stats(), m.limiters.ServerStats()
+}
+
+// applyConcurrencyLimits republishes ONE generation of limits for every scope
+// (FR-021). Called at construction and on every config hot-reload
+// (SetGlobalConfig). Servers that are absent, disabled or quarantined are
+// retired, which promptly fails their queued calls (FR-009).
+func (m *Manager) applyConcurrencyLimits(cfg *config.Config) {
+ if m == nil || m.limiters == nil {
+ return
+ }
+ if cfg == nil {
+ cfg = &config.Config{}
+ }
+
+ servers := make(map[string]limiter.Limits)
+ for _, sc := range cfg.Servers {
+ if !limiterEligible(sc) {
+ continue
+ }
+ servers[sc.Name] = toLimiterLimits(cfg.ResolveServerConcurrency(sc))
+ }
+
+ // Clients added out-of-band (AddServerConfig before the new config snapshot
+ // reaches the manager) must keep their limiter across the generation swap.
+ m.mu.RLock()
+ clientConfigs := make([]*config.ServerConfig, 0, len(m.clients))
+ for _, client := range m.clients {
+ if client != nil {
+ clientConfigs = append(clientConfigs, client.GetConfig())
+ }
+ }
+ m.mu.RUnlock()
+
+ for _, sc := range clientConfigs {
+ if !limiterEligible(sc) {
+ continue
+ }
+ if _, ok := servers[sc.Name]; ok {
+ continue
+ }
+ servers[sc.Name] = toLimiterLimits(cfg.ResolveServerConcurrency(sc))
+ }
+
+ m.limiters.Apply(toLimiterLimits(cfg.ResolveGlobalConcurrency()), servers)
+}
+
+// applyServerConcurrency republishes one server's limits (add / update path).
+// A disabled or quarantined server is retired instead (FR-009).
+func (m *Manager) applyServerConcurrency(sc *config.ServerConfig) {
+ if m == nil || m.limiters == nil || sc == nil || sc.Name == "" {
+ return
+ }
+ if !limiterEligible(sc) {
+ m.limiters.RetireServer(sc.Name)
+ return
+ }
+ cfg := m.globalConfig.Load()
+ if cfg == nil {
+ cfg = &config.Config{}
+ }
+ m.limiters.SetServer(sc.Name, toLimiterLimits(cfg.ResolveServerConcurrency(sc)))
+}
+
+// retireServerConcurrency tombstones a removed server's limiter so its queued
+// calls fail immediately instead of waiting out the queue deadline (FR-009).
+func (m *Manager) retireServerConcurrency(name string) {
+ if m == nil || m.limiters == nil || name == "" {
+ return
+ }
+ m.limiters.RetireServer(name)
+}
diff --git a/internal/upstream/concurrency_test.go b/internal/upstream/concurrency_test.go
new file mode 100644
index 000000000..5a2b2572b
--- /dev/null
+++ b/internal/upstream/concurrency_test.go
@@ -0,0 +1,308 @@
+package upstream
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.uber.org/zap"
+
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/secret"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/upstream/limiter"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/upstream/types"
+)
+
+func intPtr(v int) *int { return &v }
+
+func durPtrConc(d time.Duration) *config.Duration {
+ cd := config.Duration(d)
+ return &cd
+}
+
+// limitedServerConfig is a server with a 1-at-a-time limit and one queue slot.
+func limitedServerConfig(name string) *config.ServerConfig {
+ return &config.ServerConfig{
+ Name: name,
+ URL: "http://127.0.0.1:1",
+ Protocol: "http",
+ Enabled: true,
+ MaxConcurrentRequests: intPtr(1),
+ QueueSize: intPtr(1),
+ QueueTimeout: durPtrConc(30 * time.Second),
+ Created: time.Now(),
+ }
+}
+
+// newConcurrencyManager builds a real Manager (limiter registry included) with
+// one Ready client, so tool calls reach the admission seam without a live
+// upstream transport.
+func newConcurrencyManager(t *testing.T, cfg *config.Config, serverCfg *config.ServerConfig) *Manager {
+ t.Helper()
+ t.Setenv("CI", "")
+
+ m := NewManager(zap.NewNop(), cfg, nil, secret.NewResolver(), nil)
+ t.Cleanup(func() { m.shutdownCancel() })
+
+ require.NoError(t, m.AddServerConfig(serverCfg.Name, serverCfg))
+ client, ok := m.GetClient(serverCfg.Name)
+ require.True(t, ok)
+ client.StateManager.TransitionTo(types.StateConnecting)
+ client.StateManager.TransitionTo(types.StateReady)
+ require.True(t, client.IsConnected())
+ return m
+}
+
+// TestManagerCallTool_QueuedCallDoesNotBlockServerManagement is the FR-008
+// regression test. Manager.CallTool used to hold m.mu.RLock for the entire
+// call, so a call parked in the limiter queue would stall every AddServer /
+// RemoveServer / config reload behind the manager lock.
+func TestManagerCallTool_QueuedCallDoesNotBlockServerManagement(t *testing.T) {
+ serverCfg := limitedServerConfig("slow-server")
+ cfg := &config.Config{Servers: []*config.ServerConfig{serverCfg}}
+ m := newConcurrencyManager(t, cfg, serverCfg)
+
+ lim := m.Limiters().Server("slow-server")
+ require.NotNil(t, lim, "a per-server limit must produce a limiter instance")
+
+ // Occupy the single slot so the tool call below has to queue.
+ release, err := lim.Acquire(context.Background(), time.Time{})
+ require.NoError(t, err)
+
+ callDone := make(chan error, 1)
+ go func() {
+ _, callErr := m.CallTool(context.Background(), "slow-server:some_tool", map[string]interface{}{})
+ callDone <- callErr
+ }()
+
+ require.Eventually(t, func() bool { return lim.Stats().Queued == 1 },
+ 2*time.Second, 5*time.Millisecond, "call must park in the limiter queue")
+
+ // With the lock held across the call, this would block until the queued
+ // call finished. It must complete promptly instead.
+ mgmtDone := make(chan error, 1)
+ go func() {
+ other := &config.ServerConfig{Name: "other", URL: "http://127.0.0.1:2", Protocol: "http", Enabled: true}
+ mgmtDone <- m.AddServerConfig("other", other)
+ }()
+ select {
+ case err := <-mgmtDone:
+ require.NoError(t, err)
+ case <-time.After(2 * time.Second):
+ t.Fatal("AddServerConfig blocked behind a queued tool call (FR-008 regression)")
+ }
+
+ // FR-009: removing the server must fail the queued call immediately rather
+ // than leaving it to hit the 30s queue timeout.
+ m.RemoveServer("slow-server")
+ select {
+ case callErr := <-callDone:
+ require.Error(t, callErr)
+ assert.True(t, errors.Is(callErr, limiter.ErrServerUnavailable),
+ "queued call must fail with server-unavailable, got: %v", callErr)
+ case <-time.After(2 * time.Second):
+ t.Fatal("removing a server did not promptly fail its queued call (FR-009)")
+ }
+
+ release()
+}
+
+// TestManagerCallTool_ConcurrentWithServerChurn is the -race deadlock guard for
+// the restructured lock scope: dispatch must interleave freely with
+// add/remove/config-reload.
+func TestManagerCallTool_ConcurrentWithServerChurn(t *testing.T) {
+ serverCfg := limitedServerConfig("churn-server")
+ cfg := &config.Config{Servers: []*config.ServerConfig{serverCfg}}
+ m := newConcurrencyManager(t, cfg, serverCfg)
+
+ var wg sync.WaitGroup
+ stop := make(chan struct{})
+
+ for i := 0; i < 8; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for {
+ select {
+ case <-stop:
+ return
+ default:
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
+ _, _ = m.CallTool(ctx, "churn-server:tool", map[string]interface{}{})
+ cancel()
+ }
+ }()
+ }
+
+ for i := 0; i < 4; i++ {
+ wg.Add(1)
+ go func(id int) {
+ defer wg.Done()
+ name := fmt.Sprintf("churn-%d", id)
+ for n := 0; n < 25; n++ {
+ select {
+ case <-stop:
+ return
+ default:
+ }
+ sc := &config.ServerConfig{Name: name, URL: "http://127.0.0.1:3", Protocol: "http", Enabled: true}
+ _ = m.AddServerConfig(name, sc)
+ m.RemoveServer(name)
+ m.SetGlobalConfig(cfg)
+ }
+ }(i)
+ }
+
+ done := make(chan struct{})
+ go func() {
+ time.Sleep(750 * time.Millisecond)
+ close(stop)
+ wg.Wait()
+ close(done)
+ }()
+
+ select {
+ case <-done:
+ case <-time.After(20 * time.Second):
+ t.Fatal("deadlock: dispatch and server churn did not complete")
+ }
+}
+
+// TestApplyConcurrencyLimits_HotReload covers FR-021 at the manager level: a new
+// config generation republishes limits into the SAME limiter instance, so
+// occupancy survives the swap.
+func TestApplyConcurrencyLimits_HotReload(t *testing.T) {
+ serverCfg := limitedServerConfig("hot")
+ cfg := &config.Config{Servers: []*config.ServerConfig{serverCfg}}
+ m := newConcurrencyManager(t, cfg, serverCfg)
+
+ lim := m.Limiters().Server("hot")
+ require.NotNil(t, lim)
+ assert.Equal(t, 1, lim.Limits().Max)
+
+ release, err := lim.Acquire(context.Background(), time.Time{})
+ require.NoError(t, err)
+ assert.Equal(t, 1, lim.Stats().Running)
+
+ raised := limitedServerConfig("hot")
+ raised.MaxConcurrentRequests = intPtr(4)
+ m.SetGlobalConfig(&config.Config{Servers: []*config.ServerConfig{raised}})
+
+ assert.Same(t, lim, m.Limiters().Server("hot"), "hot reload must mutate the live instance, not replace it")
+ assert.Equal(t, 4, lim.Limits().Max)
+ assert.Equal(t, 1, lim.Stats().Running, "occupancy must be shared across generations")
+ release()
+}
+
+// TestApplyConcurrencyLimits_GlobalAndDisabledServers verifies scope wiring:
+// the global aggregate limiter is created from the top-level settings, and a
+// disabled or quarantined server never owns a limiter (FR-009).
+func TestApplyConcurrencyLimits_GlobalAndDisabledServers(t *testing.T) {
+ t.Setenv("CI", "")
+
+ enabled := limitedServerConfig("on")
+ disabled := limitedServerConfig("off")
+ disabled.Enabled = false
+ quarantined := limitedServerConfig("quar")
+ quarantined.Quarantined = true
+
+ cfg := &config.Config{
+ MaxConcurrentRequests: intPtr(10),
+ QueueSize: intPtr(5),
+ QueueTimeout: durPtrConc(2 * time.Second),
+ Servers: []*config.ServerConfig{enabled, disabled, quarantined},
+ }
+
+ m := NewManager(zap.NewNop(), cfg, nil, secret.NewResolver(), nil)
+ t.Cleanup(func() { m.shutdownCancel() })
+
+ global := m.Limiters().Global()
+ require.NotNil(t, global)
+ assert.Equal(t, 10, global.Limits().Max)
+ assert.Equal(t, 5, global.Limits().QueueSize)
+ assert.Equal(t, 2*time.Second, global.Limits().QueueTimeout)
+
+ assert.NotNil(t, m.Limiters().Server("on"))
+ assert.Nil(t, m.Limiters().Server("off"), "a disabled server must not own a limiter")
+ assert.Nil(t, m.Limiters().Server("quar"), "a quarantined server must not own a limiter")
+}
+
+// TestZeroConfig_AdmissionIsPassthroughButCounted is the FR-006 guard paired
+// with FR-021's shared occupancy. With no limits configured nothing is capped,
+// queued or rejected — but the scopes still COUNT their running calls, because
+// a cap enabled by a later hot reload has to see the calls already in flight.
+func TestZeroConfig_AdmissionIsPassthroughButCounted(t *testing.T) {
+ t.Setenv("CI", "")
+
+ sc := &config.ServerConfig{Name: "plain", URL: "http://127.0.0.1:1", Protocol: "http", Enabled: true}
+ cfg := &config.Config{Servers: []*config.ServerConfig{sc}}
+ m := NewManager(zap.NewNop(), cfg, nil, secret.NewResolver(), nil)
+ t.Cleanup(func() { m.shutdownCancel() })
+
+ assert.Equal(t, 0, m.Limiters().Global().Limits().Max, "no global cap must be published")
+ plain := m.Limiters().Server("plain")
+ require.NotNil(t, plain)
+ assert.Equal(t, 0, plain.Limits().Max, "no per-server cap must be published")
+
+ release, err := m.Limiters().Acquire(context.Background(), "plain")
+ require.NoError(t, err, "an unlimited scope never sheds")
+ assert.Equal(t, 1, plain.Stats().Running, "occupancy is tracked even with no cap")
+ assert.Equal(t, 0, plain.Stats().Queued, "an unlimited scope never queues")
+ release()
+ assert.Equal(t, 0, plain.Stats().Running)
+}
+
+// TestHotEnableSeesGrandfatheredCalls is the FR-021 regression test for the
+// shared-occupancy rule at the point it is easiest to get wrong: enabling a cap
+// on a scope that was previously UNLIMITED. Before the fix the new limiter
+// started at running==0 while unlimited calls were still in flight, so the cap
+// was exceeded by exactly the grandfathered count for as long as they ran.
+func TestHotEnableSeesGrandfatheredCalls(t *testing.T) {
+ t.Setenv("CI", "")
+
+ sc := &config.ServerConfig{Name: "late", URL: "http://127.0.0.1:1", Protocol: "http", Enabled: true}
+ cfg := &config.Config{Servers: []*config.ServerConfig{sc}}
+ m := NewManager(zap.NewNop(), cfg, nil, secret.NewResolver(), nil)
+ t.Cleanup(func() { m.shutdownCancel() })
+
+ // Three unlimited calls in flight.
+ const inFlight = 3
+ releases := make([]func(), 0, inFlight)
+ for i := 0; i < inFlight; i++ {
+ release, err := m.Limiters().Acquire(context.Background(), "late")
+ require.NoError(t, err)
+ releases = append(releases, release)
+ }
+
+ // Operator hot-enables a cap BELOW the number already running.
+ capped := &config.ServerConfig{
+ Name: "late", URL: "http://127.0.0.1:1", Protocol: "http", Enabled: true,
+ MaxConcurrentRequests: intPtr(2),
+ QueueSize: intPtr(0),
+ QueueTimeout: durPtrConc(30 * time.Second),
+ }
+ m.SetGlobalConfig(&config.Config{Servers: []*config.ServerConfig{capped}})
+
+ lim := m.Limiters().Server("late")
+ require.NotNil(t, lim)
+ assert.Equal(t, inFlight, lim.Stats().Running,
+ "the newly enabled cap must inherit the grandfathered occupancy")
+
+ _, err := m.Limiters().Acquire(context.Background(), "late")
+ require.Error(t, err, "no new admission until occupancy drains below the new cap")
+ assert.True(t, errors.Is(err, limiter.ErrQueueFull))
+
+ // Drain to one below the cap; the next call is admitted again.
+ releases[0]()
+ releases[1]()
+ release, err := m.Limiters().Acquire(context.Background(), "late")
+ require.NoError(t, err, "admission resumes once occupancy drops below the cap")
+ release()
+ releases[2]()
+}
diff --git a/internal/upstream/limiter/errors.go b/internal/upstream/limiter/errors.go
new file mode 100644
index 000000000..681f94404
--- /dev/null
+++ b/internal/upstream/limiter/errors.go
@@ -0,0 +1,123 @@
+package limiter
+
+import (
+ "errors"
+ "fmt"
+ "time"
+)
+
+// Scope identifies which limiter tier produced a decision (spec 093, FR-010).
+type Scope string
+
+const (
+ // ScopeServer is a per-upstream-server limiter.
+ ScopeServer Scope = "server"
+ // ScopeGlobal is the proxy-wide aggregate limiter.
+ ScopeGlobal Scope = "global"
+)
+
+// Reason is the stable machine-readable cause of a rejection (FR-012).
+type Reason string
+
+const (
+ // ReasonQueueFull means the wait queue had no pending capacity, so the call
+ // was shed immediately without waiting.
+ ReasonQueueFull Reason = "queue_full"
+ // ReasonQueueTimeout means the call waited past its absolute queue deadline.
+ ReasonQueueTimeout Reason = "queue_timeout"
+ // ReasonServerUnavailable means the target server was disabled, quarantined
+ // or removed while the call was queued (or just before it enqueued).
+ ReasonServerUnavailable Reason = "server_unavailable"
+)
+
+// Sentinel errors for errors.Is matching at the shed-semantics seams (MCP
+// isError results, REST 429 mapping, activity "rejected" status). The concrete
+// error returned by Acquire is always a *LimitError, which reports Is() true
+// for the sentinel matching its Reason.
+var (
+ // ErrQueueFull matches a rejection caused by a full (or zero-sized) queue.
+ ErrQueueFull = errors.New("concurrency limit: queue full")
+ // ErrQueueTimeout matches a rejection caused by the queue deadline expiring.
+ ErrQueueTimeout = errors.New("concurrency limit: queue timeout")
+ // ErrServerUnavailable matches a rejection caused by the target server being
+ // disabled/quarantined/removed (FR-009).
+ ErrServerUnavailable = errors.New("concurrency limit: server unavailable")
+)
+
+// LimitError is the typed rejection identity that must survive end-to-end
+// through every dispatch path (FR-011). It carries everything the shed seams
+// need: which tier shed the call, why, which server (empty for the global
+// scope — the message must never blame a server for a proxy-wide limit), the
+// limit that was in force, and the Retry-After hint derived from the shedding
+// scope's effective queue_timeout.
+type LimitError struct {
+ Scope Scope
+ Reason Reason
+ Server string
+ Limit int
+ RetryAfter time.Duration
+}
+
+func (e *LimitError) Error() string {
+ switch {
+ case e.Reason == ReasonServerUnavailable:
+ if e.Server != "" {
+ return fmt.Sprintf("upstream %q is not available (disabled, quarantined or removed)", e.Server)
+ }
+ return "upstream is not available (disabled, quarantined or removed)"
+ case e.Scope == ScopeGlobal:
+ return fmt.Sprintf("mcpproxy is busy: the proxy-wide concurrency limit (%d) is saturated (%s) — please retry shortly",
+ e.Limit, e.Reason)
+ default:
+ return fmt.Sprintf("upstream server %q is busy: its concurrency limit (%d) is saturated (%s) — please retry shortly",
+ e.Server, e.Limit, e.Reason)
+ }
+}
+
+// RetryAdvice is the closing sentence of every shed message. Agents read
+// tool-call error text; telling them the failure is transient is what turns a
+// rejection into a retry instead of an abandoned task (FR-010).
+const RetryAdvice = "Retry in a few seconds."
+
+// UserMessage renders the caller-facing explanation of a shed (FR-010). It is
+// the single rendering of that text: the MCP isError result, the REST 429 body
+// and the activity record all use it, so an operator never sees two different
+// descriptions of the same rejection.
+//
+// The global branch NEVER names a server: the proxy-wide limiter shed the call
+// because the whole instance is saturated, and blaming the upstream the call
+// happened to target would send an operator debugging the wrong thing.
+func (e *LimitError) UserMessage() string {
+ if e.Reason == ReasonServerUnavailable {
+ return e.Error()
+ }
+
+ cause := "its wait queue is full"
+ if e.Reason == ReasonQueueTimeout {
+ cause = "the call waited longer than the configured queue timeout"
+ }
+
+ if e.Scope == ScopeGlobal {
+ return fmt.Sprintf(
+ "mcpproxy is busy: the proxy-wide concurrency limit of %d simultaneous tool calls is saturated and %s. %s",
+ e.Limit, cause, RetryAdvice)
+ }
+ return fmt.Sprintf(
+ "Server %q is busy: it is already running its maximum of %d simultaneous tool calls and %s. %s",
+ e.Server, e.Limit, cause, RetryAdvice)
+}
+
+// Is makes errors.Is(err, ErrQueueFull|ErrQueueTimeout|ErrServerUnavailable)
+// work against the typed error.
+func (e *LimitError) Is(target error) bool {
+ switch target {
+ case ErrQueueFull:
+ return e.Reason == ReasonQueueFull
+ case ErrQueueTimeout:
+ return e.Reason == ReasonQueueTimeout
+ case ErrServerUnavailable:
+ return e.Reason == ReasonServerUnavailable
+ default:
+ return false
+ }
+}
diff --git a/internal/upstream/limiter/limiter.go b/internal/upstream/limiter/limiter.go
new file mode 100644
index 000000000..5e096d69c
--- /dev/null
+++ b/internal/upstream/limiter/limiter.go
@@ -0,0 +1,406 @@
+// Package limiter implements the bounded-concurrency admission control used by
+// the upstream tool-call choke point (spec 093, GH #955).
+//
+// A Limiter caps the number of concurrently *running* calls in one scope (a
+// single upstream server, or the proxy-wide aggregate) and parks excess calls
+// in a bounded FIFO wait queue. Callers acquire with an absolute queue
+// deadline that is shared across tiers (FR-004): waiting for a per-server slot
+// and then for a global slot must never grant two full queue timeouts.
+//
+// A Limiter owns OCCUPANCY (how many calls are running, who is queued, whether
+// the scope is retired) and nothing else. It never stores the limits themselves:
+// those live in the registry's immutable published generation, and every
+// admission decision is made against the values handed to it, so one admission
+// can never combine one scope's new cap with another scope's old one (FR-021).
+//
+// Implementation note: the queue is a hand-rolled FIFO waiter list guarded by
+// one mutex rather than golang.org/x/sync/semaphore. A semaphore's capacity is
+// fixed at construction, but FR-021 requires occupancy to be *shared across
+// generations* on hot reload — lowering a cap must not grant new capacity
+// until the grandfathered running calls drain, and raising it must admit
+// eligible waiters immediately. Both need a resizable cap over a live
+// occupancy counter, which the waiter list gives us directly.
+package limiter
+
+import (
+ "container/list"
+ "context"
+ "fmt"
+ "sync"
+ "sync/atomic"
+ "time"
+)
+
+// Limits is one scope's configured concurrency settings, already resolved from
+// the config tri-states. A Limits value is IMMUTABLE once published: a reload
+// publishes a new value, it never edits one in place.
+type Limits struct {
+ // Max is the maximum number of concurrently running calls. <= 0 means the
+ // scope does not limit anything (occupancy is still tracked so a later
+ // hot-reload that enables the limit sees the in-flight calls).
+ Max int
+ // QueueSize is the number of calls allowed to wait for a slot. 0 means no
+ // pending capacity: calls arriving at the cap are shed immediately.
+ QueueSize int
+ // QueueTimeout is the scope's configured wait budget. The limiter itself
+ // takes an absolute deadline from the caller; this value is reported as the
+ // Retry-After hint on rejections produced by this scope.
+ QueueTimeout time.Duration
+}
+
+// Enabled reports whether this scope actually caps concurrency.
+func (l Limits) Enabled() bool { return l.Max > 0 }
+
+// Stats is a point-in-time view of one scope's occupancy, used by the metrics
+// surface (FR-013).
+type Stats struct {
+ Running int
+ Queued int
+}
+
+// noopRelease is returned by every admission path that did not take a slot.
+func noopRelease() {}
+
+type waiter struct {
+ ch chan struct{}
+ el *list.Element
+ granted bool
+ retired bool
+}
+
+// Limiter guards one scope. The zero value is not usable; use New. A nil
+// *Limiter is a valid no-op passthrough so callers can skip nil checks.
+type Limiter struct {
+ scope Scope
+ server string
+
+ // limitsFn returns the limits currently published for this scope. It is the
+ // ONLY way the limiter learns a cap, and it must never take a lock: it is
+ // called while holding l.mu (registry instances resolve it from one atomic
+ // generation load). Note where it is and is not used — the admission
+ // DECISION never calls it, it uses the values passed to acquire so that the
+ // caps, the queue budget and the reported rejection all come from the single
+ // generation that admission resolved (FR-021). Handing a freed slot to a
+ // queued waiter does call it, deliberately: a waiter must be admitted under
+ // the newest published cap, and that decision involves this scope alone, so
+ // there is no cross-scope value to mix.
+ limitsFn func() Limits
+
+ // held is the fixed-limits cell backing a STANDALONE limiter (New): it is
+ // what setLimits swaps. Registry scopes leave it nil — their limits belong
+ // to the published generation and are never edited in place.
+ held *atomic.Pointer[Limits]
+
+ mu sync.Mutex
+ running int
+ waiters *list.List // of *waiter, FIFO
+ retired bool
+
+ // waiterWoke runs on a granted waiter's goroutine between its channel
+ // closing and the post-wake re-check below. It exists so a test can land a
+ // Retire() precisely inside that window (the grant-vs-retire interleaving
+ // FR-009 turns on); nil everywhere else.
+ waiterWoke func()
+}
+
+// New builds a STANDALONE limiter with fixed limits. Registry-owned scopes use
+// newPublished instead, so their limits always come from the current generation.
+// server is the upstream name for ScopeServer and must be empty for ScopeGlobal
+// (a global rejection must never blame a server, FR-010).
+func New(scope Scope, server string, limits Limits) *Limiter {
+ var held atomic.Pointer[Limits]
+ held.Store(&limits)
+ l := newPublished(scope, server, func() Limits { return *held.Load() })
+ l.held = &held
+ return l
+}
+
+// newPublished builds a limiter whose limits are resolved from the registry's
+// published generation on every read.
+func newPublished(scope Scope, server string, limitsFn func() Limits) *Limiter {
+ if scope == ScopeGlobal {
+ server = ""
+ }
+ return &Limiter{
+ scope: scope,
+ server: server,
+ limitsFn: limitsFn,
+ waiters: list.New(),
+ }
+}
+
+// Limits returns the limits currently published for this scope.
+func (l *Limiter) Limits() Limits {
+ if l == nil || l.limitsFn == nil {
+ return Limits{}
+ }
+ return l.limitsFn()
+}
+
+// Stats returns the current occupancy and queue depth.
+func (l *Limiter) Stats() Stats {
+ if l == nil {
+ return Stats{}
+ }
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ return Stats{Running: l.running, Queued: l.waiters.Len()}
+}
+
+// setLimits republishes a STANDALONE limiter's limits and re-evaluates the
+// queue against them. Registry scopes are not updated this way — their limits
+// change by publishing a new generation, after which the registry calls regrant.
+func (l *Limiter) setLimits(limits Limits) {
+ if l == nil || l.held == nil {
+ return
+ }
+ l.held.Store(&limits)
+ l.regrant()
+}
+
+// regrant re-evaluates the wait queue against the CURRENTLY published limits.
+// The registry calls this on every scope right after publishing a generation,
+// which is what makes "a raise admits eligible waiters immediately" true while
+// "a lowered cap admits nothing until occupancy drains" stays true — the same
+// comparison decides both.
+func (l *Limiter) regrant() {
+ if l == nil {
+ return
+ }
+ l.mu.Lock()
+ l.grantLocked()
+ l.mu.Unlock()
+}
+
+// Retire marks the scope dead (server disabled, quarantined or removed) and
+// fails every queued call promptly with ErrServerUnavailable instead of
+// letting them sit until the queue deadline (FR-009). Outstanding holds keep
+// draining into this instance; a re-added server gets a fresh instance so its
+// capacity is never double-counted against the retired one.
+func (l *Limiter) Retire() {
+ if l == nil {
+ return
+ }
+ l.mu.Lock()
+ l.retired = true
+ for e := l.waiters.Front(); e != nil; {
+ next := e.Next()
+ w, _ := e.Value.(*waiter)
+ w.retired = true
+ w.el = nil
+ close(w.ch)
+ l.waiters.Remove(e)
+ e = next
+ }
+ l.mu.Unlock()
+}
+
+// Retired reports whether this instance has been retired.
+func (l *Limiter) Retired() bool {
+ if l == nil {
+ return false
+ }
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ return l.retired
+}
+
+// Acquire admits one call into the scope under this limiter's currently
+// published limits, waiting in the bounded FIFO queue if the cap is reached.
+// queueDeadline is the ABSOLUTE deadline for the whole admission (shared across
+// tiers, FR-004); the zero time means "wait until the caller's context ends".
+//
+// The registry does NOT use this entry point: a two-tier admission must resolve
+// both scopes from one generation, which is what acquire takes as an argument.
+//
+// It returns an idempotent release closure bound to this instance, or:
+// - *LimitError{Reason: queue_full} when there is no pending capacity,
+// - *LimitError{Reason: queue_timeout} when the deadline expired while queued,
+// - *LimitError{Reason: server_unavailable} when the scope was retired,
+// - the caller's context error (context.Canceled / DeadlineExceeded) when the
+// CALLER's context ended while queued — never reported as shedding (FR-005).
+func (l *Limiter) Acquire(ctx context.Context, queueDeadline time.Time) (func(), error) {
+ if l == nil {
+ return noopRelease, nil
+ }
+ return l.acquire(ctx, l.Limits(), queueDeadline)
+}
+
+// acquire admits one call under the limits GIVEN to it. Those values decide
+// everything the admission observes — whether there is capacity, whether there
+// is pending queue capacity, and what a rejection reports — so an admission is
+// governed end-to-end by the one generation it resolved, never by a value that
+// changed underneath it (FR-021).
+func (l *Limiter) acquire(ctx context.Context, limits Limits, queueDeadline time.Time) (func(), error) {
+ if l == nil {
+ return noopRelease, nil
+ }
+
+ l.mu.Lock()
+ if l.retired {
+ l.mu.Unlock()
+ return nil, l.unavailableError()
+ }
+ // Hand capacity to the calls already in line BEFORE considering this one.
+ // A cap raise creates free capacity, and publishing it is necessarily two
+ // steps (store the generation, then re-grant); without this, a call arriving
+ // in between would see the free slot on the fast path and take it in front
+ // of a waiter who has been queued for the whole reload. Draining first also
+ // means the checks below judge capacity that is genuinely spare.
+ l.grantLocked()
+
+ // Fast path: unlimited scope, or free capacity — but never past a queue.
+ // Jumping the line is what FIFO forbids (FR-004), and it is reachable
+ // whenever this admission's generation is more permissive than the one the
+ // waiters were last measured against.
+ if l.waiters.Len() == 0 && (limits.Max <= 0 || l.running < limits.Max) {
+ l.running++
+ l.mu.Unlock()
+ return l.releaseFunc(), nil
+ }
+ // Saturated: is there pending capacity?
+ if l.waiters.Len() >= limits.QueueSize {
+ l.mu.Unlock()
+ return nil, l.limitError(ReasonQueueFull, limits)
+ }
+ w := &waiter{ch: make(chan struct{})}
+ w.el = l.waiters.PushBack(w)
+ l.mu.Unlock()
+
+ var timerC <-chan time.Time
+ if !queueDeadline.IsZero() {
+ timer := time.NewTimer(time.Until(queueDeadline))
+ defer timer.Stop()
+ timerC = timer.C
+ }
+
+ select {
+ case <-w.ch:
+ // Granted or retired — both close the channel. The re-check runs under
+ // the limiter's own lock, which is what makes grant-vs-retire atomic
+ // (FR-009): Retire flips l.retired under that same lock, so a waiter
+ // granted a slot moments before retirement observes the retirement here
+ // and hands the slot straight back instead of running against a server
+ // that is no longer admitting work.
+ if l.waiterWoke != nil {
+ l.waiterWoke()
+ }
+ l.mu.Lock()
+ retired := w.retired || l.retired
+ if retired && w.granted {
+ l.releaseLocked()
+ }
+ l.mu.Unlock()
+ if retired {
+ return nil, l.unavailableError()
+ }
+ return l.releaseFunc(), nil
+
+ case <-timerC:
+ // A grant that raced the deadline wins: abandon returns nil and the
+ // caller keeps the slot rather than losing already-granted capacity.
+ if err := l.abandon(w, l.limitError(ReasonQueueTimeout, limits), true); err != nil {
+ return nil, err
+ }
+ return l.releaseFunc(), nil
+
+ case <-ctx.Done():
+ cause := context.Cause(ctx)
+ if cause == nil {
+ cause = ctx.Err()
+ }
+ return nil, l.abandon(w, fmt.Errorf("call cancelled while waiting for a concurrency slot: %w", cause), false)
+ }
+}
+
+// abandon removes a waiter that stopped waiting. If the waiter was granted a
+// slot in the meantime, the slot is either kept (grantWins: the grant raced the
+// deadline, so honour it) or handed to the next waiter (caller is gone).
+// It returns the error to report, or nil when the grant is honoured.
+func (l *Limiter) abandon(w *waiter, reportErr error, grantWins bool) error {
+ l.mu.Lock()
+ switch {
+ case w.retired || l.retired:
+ // Same atomicity rule as the wake path: a slot granted just before
+ // retirement is given back rather than honoured.
+ if w.granted {
+ l.releaseLocked()
+ }
+ l.mu.Unlock()
+ return l.unavailableError()
+ case w.granted && grantWins:
+ l.mu.Unlock()
+ return nil // caller keeps the slot; see Acquire's callers below
+ case w.granted:
+ // Caller is gone: give the slot straight back to the queue.
+ l.releaseLocked()
+ l.mu.Unlock()
+ return reportErr
+ default:
+ if w.el != nil {
+ l.waiters.Remove(w.el)
+ w.el = nil
+ }
+ l.mu.Unlock()
+ return reportErr
+ }
+}
+
+// limitError describes a shed produced by THIS scope. RetryAfter is the scope's
+// EFFECTIVE queue timeout, not its raw configured value: a scope that caps
+// concurrency without naming a timeout still makes callers wait the fallback,
+// and reporting 0 would hand the REST surface a Retry-After that undersells the
+// wait by a factor of thirty.
+func (l *Limiter) limitError(reason Reason, limits Limits) *LimitError {
+ return &LimitError{
+ Scope: l.scope,
+ Reason: reason,
+ Server: l.server,
+ Limit: limits.Max,
+ RetryAfter: effectiveQueueTimeout(limits),
+ }
+}
+
+func (l *Limiter) unavailableError() *LimitError {
+ return &LimitError{Scope: l.scope, Reason: ReasonServerUnavailable, Server: l.server}
+}
+
+// releaseFunc returns the idempotent release closure bound to this instance.
+// Binding matters across hot-swaps and retirement: a hold taken on a retired
+// instance must drain into that instance, never into the fresh one.
+func (l *Limiter) releaseFunc() func() {
+ var once sync.Once
+ return func() {
+ once.Do(func() {
+ l.mu.Lock()
+ l.releaseLocked()
+ l.mu.Unlock()
+ })
+ }
+}
+
+func (l *Limiter) releaseLocked() {
+ if l.running > 0 {
+ l.running--
+ }
+ l.grantLocked()
+}
+
+// grantLocked hands free capacity to the head of the FIFO queue, under the
+// limits published RIGHT NOW — a waiter must be admitted against the current
+// generation, not against the one it arrived under.
+func (l *Limiter) grantLocked() {
+ if l.waiters.Len() == 0 {
+ return // nothing to grant; keeps the uncontended path free of a limits read
+ }
+ limits := l.Limits()
+ for l.waiters.Len() > 0 && (limits.Max <= 0 || l.running < limits.Max) {
+ e := l.waiters.Front()
+ w, _ := e.Value.(*waiter)
+ l.waiters.Remove(e)
+ w.el = nil
+ w.granted = true
+ l.running++
+ close(w.ch)
+ }
+}
diff --git a/internal/upstream/limiter/limiter_test.go b/internal/upstream/limiter/limiter_test.go
new file mode 100644
index 000000000..ca8483888
--- /dev/null
+++ b/internal/upstream/limiter/limiter_test.go
@@ -0,0 +1,644 @@
+package limiter
+
+import (
+ "context"
+ "errors"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+// deadlineIn returns an absolute queue deadline d from now (FR-004: the queue
+// deadline is one absolute deadline shared across tiers, not a per-step timeout).
+func deadlineIn(d time.Duration) time.Time { return time.Now().Add(d) }
+
+func TestNilLimiterIsPassthrough(t *testing.T) {
+ var l *Limiter
+ release, err := l.Acquire(context.Background(), deadlineIn(time.Millisecond))
+ if err != nil {
+ t.Fatalf("nil limiter Acquire returned error: %v", err)
+ }
+ if release == nil {
+ t.Fatal("nil limiter Acquire returned nil release func")
+ }
+ release()
+ release() // must be safe to call twice
+}
+
+func TestZeroMaxIsPassthrough(t *testing.T) {
+ l := New(ScopeServer, "srv", Limits{})
+
+ const n = 50
+ releases := make([]func(), 0, n)
+ for i := 0; i < n; i++ {
+ release, err := l.Acquire(context.Background(), deadlineIn(10*time.Millisecond))
+ if err != nil {
+ t.Fatalf("acquire %d: %v", i, err)
+ }
+ releases = append(releases, release)
+ }
+ if got := l.Stats().Running; got != n {
+ t.Fatalf("Running = %d, want %d (occupancy is tracked even when unlimited)", got, n)
+ }
+ for _, r := range releases {
+ r()
+ }
+ if got := l.Stats().Running; got != 0 {
+ t.Fatalf("Running after release = %d, want 0", got)
+ }
+}
+
+func TestMaxConcurrencyIsNeverExceeded(t *testing.T) {
+ const max = 3
+ l := New(ScopeServer, "srv", Limits{Max: max, QueueSize: 64, QueueTimeout: 5 * time.Second})
+
+ var current, peak int64
+ var wg sync.WaitGroup
+ deadline := deadlineIn(5 * time.Second)
+ for i := 0; i < 40; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ release, err := l.Acquire(context.Background(), deadline)
+ if err != nil {
+ t.Errorf("acquire: %v", err)
+ return
+ }
+ defer release()
+ cur := atomic.AddInt64(¤t, 1)
+ for {
+ old := atomic.LoadInt64(&peak)
+ if cur <= old || atomic.CompareAndSwapInt64(&peak, old, cur) {
+ break
+ }
+ }
+ time.Sleep(2 * time.Millisecond)
+ atomic.AddInt64(¤t, -1)
+ }()
+ }
+ wg.Wait()
+
+ if peak > max {
+ t.Fatalf("peak concurrency = %d, want <= %d", peak, max)
+ }
+ if st := l.Stats(); st.Running != 0 || st.Queued != 0 {
+ t.Fatalf("stats after drain = %+v, want zero", st)
+ }
+}
+
+func TestQueueFullRejectsImmediately(t *testing.T) {
+ l := New(ScopeServer, "github", Limits{Max: 1, QueueSize: 1, QueueTimeout: 10 * time.Second})
+
+ rel1, err := l.Acquire(context.Background(), deadlineIn(time.Second))
+ if err != nil {
+ t.Fatalf("first acquire: %v", err)
+ }
+ defer rel1()
+
+ // Second call occupies the single queue slot.
+ queued := make(chan struct{})
+ go func() {
+ close(queued)
+ rel, err := l.Acquire(context.Background(), deadlineIn(5*time.Second))
+ if err == nil {
+ rel()
+ }
+ }()
+ <-queued
+ waitFor(t, time.Second, func() bool { return l.Stats().Queued == 1 })
+
+ // Third call must be rejected instantly (SC-005: < 100ms).
+ start := time.Now()
+ _, err = l.Acquire(context.Background(), deadlineIn(10*time.Second))
+ elapsed := time.Since(start)
+ if err == nil {
+ t.Fatal("expected queue-full rejection, got nil error")
+ }
+ if elapsed > 100*time.Millisecond {
+ t.Fatalf("queue-full rejection took %v, want < 100ms", elapsed)
+ }
+ if !errors.Is(err, ErrQueueFull) {
+ t.Fatalf("error %v does not match ErrQueueFull", err)
+ }
+ var le *LimitError
+ if !errors.As(err, &le) {
+ t.Fatalf("error %v is not a *LimitError", err)
+ }
+ if le.Scope != ScopeServer || le.Server != "github" || le.Reason != ReasonQueueFull {
+ t.Fatalf("LimitError = %+v, want scope=server server=github reason=queue_full", le)
+ }
+ if le.Limit != 1 {
+ t.Fatalf("LimitError.Limit = %d, want 1", le.Limit)
+ }
+ if le.RetryAfter != 10*time.Second {
+ t.Fatalf("LimitError.RetryAfter = %v, want 10s (effective queue_timeout of the shedding scope)", le.RetryAfter)
+ }
+}
+
+func TestZeroQueueSizeShedsAtTheCap(t *testing.T) {
+ l := New(ScopeServer, "srv", Limits{Max: 1, QueueSize: 0, QueueTimeout: time.Second})
+
+ rel, err := l.Acquire(context.Background(), deadlineIn(time.Second))
+ if err != nil {
+ t.Fatalf("first acquire: %v", err)
+ }
+ defer rel()
+
+ if _, err := l.Acquire(context.Background(), deadlineIn(time.Second)); !errors.Is(err, ErrQueueFull) {
+ t.Fatalf("queue_size 0 must shed at the cap, got %v", err)
+ }
+}
+
+func TestQueueTimeoutUsesAbsoluteDeadline(t *testing.T) {
+ l := New(ScopeGlobal, "", Limits{Max: 1, QueueSize: 4, QueueTimeout: 80 * time.Millisecond})
+
+ rel, err := l.Acquire(context.Background(), deadlineIn(time.Second))
+ if err != nil {
+ t.Fatalf("first acquire: %v", err)
+ }
+ defer rel()
+
+ start := time.Now()
+ _, err = l.Acquire(context.Background(), start.Add(80*time.Millisecond))
+ elapsed := time.Since(start)
+ if !errors.Is(err, ErrQueueTimeout) {
+ t.Fatalf("error %v does not match ErrQueueTimeout", err)
+ }
+ if elapsed < 60*time.Millisecond {
+ t.Fatalf("timed out after %v, want >= ~80ms (absolute deadline honored)", elapsed)
+ }
+ var le *LimitError
+ if !errors.As(err, &le) {
+ t.Fatalf("error %v is not a *LimitError", err)
+ }
+ if le.Scope != ScopeGlobal {
+ t.Fatalf("scope = %s, want global", le.Scope)
+ }
+ if le.Server != "" {
+ t.Fatalf("global LimitError must not name a server, got %q", le.Server)
+ }
+ if st := l.Stats(); st.Queued != 0 {
+ t.Fatalf("queued after timeout = %d, want 0 (slot released)", st.Queued)
+ }
+}
+
+func TestExpiredDeadlineShedsImmediately(t *testing.T) {
+ l := New(ScopeServer, "srv", Limits{Max: 1, QueueSize: 4, QueueTimeout: time.Second})
+ rel, err := l.Acquire(context.Background(), deadlineIn(time.Second))
+ if err != nil {
+ t.Fatalf("first acquire: %v", err)
+ }
+ defer rel()
+
+ start := time.Now()
+ _, err = l.Acquire(context.Background(), time.Now().Add(-time.Second))
+ if !errors.Is(err, ErrQueueTimeout) {
+ t.Fatalf("error %v does not match ErrQueueTimeout", err)
+ }
+ if time.Since(start) > 100*time.Millisecond {
+ t.Fatalf("expired deadline took %v to shed", time.Since(start))
+ }
+}
+
+func TestCallerCancelWhileQueuedIsNotShedding(t *testing.T) {
+ l := New(ScopeServer, "srv", Limits{Max: 1, QueueSize: 4, QueueTimeout: 10 * time.Second})
+
+ rel, err := l.Acquire(context.Background(), deadlineIn(10*time.Second))
+ if err != nil {
+ t.Fatalf("first acquire: %v", err)
+ }
+ defer rel()
+
+ ctx, cancel := context.WithCancel(context.Background())
+ errCh := make(chan error, 1)
+ go func() {
+ _, err := l.Acquire(ctx, deadlineIn(10*time.Second))
+ errCh <- err
+ }()
+ waitFor(t, time.Second, func() bool { return l.Stats().Queued == 1 })
+ cancel()
+
+ select {
+ case err := <-errCh:
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("error %v does not match context.Canceled", err)
+ }
+ if errors.Is(err, ErrQueueTimeout) || errors.Is(err, ErrQueueFull) {
+ t.Fatalf("caller cancellation must not be reported as shedding: %v", err)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("cancelled waiter did not return")
+ }
+ waitFor(t, time.Second, func() bool { return l.Stats().Queued == 0 })
+}
+
+func TestCallerDeadlineWhileQueuedPropagates(t *testing.T) {
+ l := New(ScopeServer, "srv", Limits{Max: 1, QueueSize: 4, QueueTimeout: 10 * time.Second})
+ rel, err := l.Acquire(context.Background(), deadlineIn(10*time.Second))
+ if err != nil {
+ t.Fatalf("first acquire: %v", err)
+ }
+ defer rel()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
+ defer cancel()
+ _, err = l.Acquire(ctx, deadlineIn(10*time.Second))
+ if !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("error %v does not match context.DeadlineExceeded", err)
+ }
+}
+
+func TestFIFOOrder(t *testing.T) {
+ l := New(ScopeServer, "srv", Limits{Max: 1, QueueSize: 16, QueueTimeout: 10 * time.Second})
+
+ rel, err := l.Acquire(context.Background(), deadlineIn(10*time.Second))
+ if err != nil {
+ t.Fatalf("first acquire: %v", err)
+ }
+
+ const n = 6
+ var mu sync.Mutex
+ order := make([]int, 0, n)
+ var wg sync.WaitGroup
+ for i := 0; i < n; i++ {
+ wg.Add(1)
+ go func(idx int) {
+ defer wg.Done()
+ r, err := l.Acquire(context.Background(), deadlineIn(10*time.Second))
+ if err != nil {
+ t.Errorf("waiter %d: %v", idx, err)
+ return
+ }
+ mu.Lock()
+ order = append(order, idx)
+ mu.Unlock()
+ r()
+ }(i)
+ // Serialize enqueue so arrival order is deterministic.
+ want := i + 1
+ waitFor(t, time.Second, func() bool { return l.Stats().Queued == want })
+ }
+
+ rel()
+ wg.Wait()
+
+ mu.Lock()
+ defer mu.Unlock()
+ for i, got := range order {
+ if got != i {
+ t.Fatalf("admission order = %v, want FIFO 0..%d", order, n-1)
+ }
+ }
+}
+
+func TestHotSwapLowerCapDoesNotGrantNewCapacity(t *testing.T) {
+ l := New(ScopeServer, "srv", Limits{Max: 4, QueueSize: 8, QueueTimeout: 10 * time.Second})
+
+ releases := make([]func(), 0, 4)
+ for i := 0; i < 4; i++ {
+ r, err := l.Acquire(context.Background(), deadlineIn(time.Second))
+ if err != nil {
+ t.Fatalf("acquire %d: %v", i, err)
+ }
+ releases = append(releases, r)
+ }
+
+ // Lower the cap while 4 calls are running: occupancy is shared across
+ // generations (FR-021), so no new admission may happen until it drains.
+ l.setLimits(Limits{Max: 1, QueueSize: 8, QueueTimeout: 10 * time.Second})
+
+ admitted := make(chan struct{})
+ go func() {
+ r, err := l.Acquire(context.Background(), deadlineIn(10*time.Second))
+ if err == nil {
+ close(admitted)
+ r()
+ }
+ }()
+
+ // Release three of four: occupancy 1 == cap, still no admission.
+ for i := 0; i < 3; i++ {
+ releases[i]()
+ }
+ select {
+ case <-admitted:
+ t.Fatal("new call admitted while occupancy still at the lowered cap")
+ case <-time.After(150 * time.Millisecond):
+ }
+
+ releases[3]()
+ select {
+ case <-admitted:
+ case <-time.After(2 * time.Second):
+ t.Fatal("queued call was not admitted after occupancy drained")
+ }
+}
+
+func TestHotSwapRaiseAdmitsQueuedImmediately(t *testing.T) {
+ l := New(ScopeServer, "srv", Limits{Max: 1, QueueSize: 8, QueueTimeout: 10 * time.Second})
+
+ rel, err := l.Acquire(context.Background(), deadlineIn(10*time.Second))
+ if err != nil {
+ t.Fatalf("first acquire: %v", err)
+ }
+ defer rel()
+
+ admitted := make(chan struct{}, 2)
+ for i := 0; i < 2; i++ {
+ go func() {
+ r, err := l.Acquire(context.Background(), deadlineIn(10*time.Second))
+ if err == nil {
+ admitted <- struct{}{}
+ r()
+ }
+ }()
+ }
+ waitFor(t, time.Second, func() bool { return l.Stats().Queued == 2 })
+
+ l.setLimits(Limits{Max: 3, QueueSize: 8, QueueTimeout: 10 * time.Second})
+
+ for i := 0; i < 2; i++ {
+ select {
+ case <-admitted:
+ case <-time.After(2 * time.Second):
+ t.Fatal("raising the cap did not admit queued calls")
+ }
+ }
+}
+
+func TestRetireFailsQueuedAndFutureAcquires(t *testing.T) {
+ l := New(ScopeServer, "srv", Limits{Max: 1, QueueSize: 8, QueueTimeout: time.Hour})
+
+ rel, err := l.Acquire(context.Background(), deadlineIn(time.Hour))
+ if err != nil {
+ t.Fatalf("first acquire: %v", err)
+ }
+
+ errCh := make(chan error, 1)
+ go func() {
+ _, err := l.Acquire(context.Background(), deadlineIn(time.Hour))
+ errCh <- err
+ }()
+ waitFor(t, time.Second, func() bool { return l.Stats().Queued == 1 })
+
+ l.Retire()
+
+ select {
+ case err := <-errCh:
+ if !errors.Is(err, ErrServerUnavailable) {
+ t.Fatalf("queued call after retire: %v, want ErrServerUnavailable", err)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("retire did not promptly fail the queued call (it waited for queue_timeout)")
+ }
+
+ // Admit-after-disable race: an acquire on a retired limiter must fail even
+ // though capacity looks free once the running call releases.
+ rel()
+ if _, err := l.Acquire(context.Background(), deadlineIn(time.Second)); !errors.Is(err, ErrServerUnavailable) {
+ t.Fatalf("acquire on retired limiter: %v, want ErrServerUnavailable", err)
+ }
+}
+
+// TestGrantRacingRetireIsObservedByTheWaiter pins the FR-009 interleaving that
+// Retire alone cannot cover: a waiter that was GRANTED a slot is already off the
+// waiter list, so Retire never sees it. Without the post-wake re-check it would
+// wake up, find no retirement flag of its own, and run a call against a server
+// that has just been disabled — while holding a slot in the retired instance.
+//
+// The waiterWoke hook makes the interleaving exact rather than probabilistic:
+// the grant has happened, the waiter is parked between the wake and the
+// re-check, and retirement lands in that window.
+func TestGrantRacingRetireIsObservedByTheWaiter(t *testing.T) {
+ l := New(ScopeServer, "srv", Limits{Max: 1, QueueSize: 4, QueueTimeout: time.Hour})
+
+ woke := make(chan struct{})
+ proceed := make(chan struct{})
+ l.waiterWoke = func() {
+ close(woke)
+ <-proceed
+ }
+
+ rel, err := l.Acquire(context.Background(), deadlineIn(time.Hour))
+ if err != nil {
+ t.Fatalf("acquire: %v", err)
+ }
+
+ errCh := make(chan error, 1)
+ go func() {
+ _, aerr := l.Acquire(context.Background(), deadlineIn(time.Hour))
+ errCh <- aerr
+ }()
+ waitFor(t, time.Second, func() bool { return l.Stats().Queued == 1 })
+
+ rel() // grants the queued waiter and closes its channel
+ <-woke // the waiter is now parked between the grant and the re-check
+ l.Retire()
+ close(proceed)
+
+ select {
+ case err := <-errCh:
+ if !errors.Is(err, ErrServerUnavailable) {
+ t.Fatalf("waiter granted just before retirement: %v, want ErrServerUnavailable", err)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("waiter never returned after retirement")
+ }
+
+ if got := l.Stats().Running; got != 0 {
+ t.Fatalf("Running = %d, want 0 — a slot granted before retirement must be handed back", got)
+ }
+}
+
+func TestReleaseIsIdempotentAndBoundToInstance(t *testing.T) {
+ l := New(ScopeServer, "srv", Limits{Max: 1, QueueSize: 1, QueueTimeout: time.Second})
+ rel, err := l.Acquire(context.Background(), deadlineIn(time.Second))
+ if err != nil {
+ t.Fatalf("acquire: %v", err)
+ }
+ rel()
+ rel()
+ rel()
+ if got := l.Stats().Running; got != 0 {
+ t.Fatalf("Running = %d after repeated release, want 0", got)
+ }
+ // A fresh acquire must still be possible (no negative occupancy leak).
+ rel2, err := l.Acquire(context.Background(), deadlineIn(time.Second))
+ if err != nil {
+ t.Fatalf("acquire after repeated release: %v", err)
+ }
+ if got := l.Stats().Running; got != 1 {
+ t.Fatalf("Running = %d, want 1", got)
+ }
+ rel2()
+}
+
+func TestConcurrentAcquireReleaseUnderSwap(t *testing.T) {
+ l := New(ScopeServer, "srv", Limits{Max: 2, QueueSize: 32, QueueTimeout: 5 * time.Second})
+
+ stop := make(chan struct{})
+ var swapWG sync.WaitGroup
+ swapWG.Add(1)
+ go func() {
+ defer swapWG.Done()
+ caps := []int{1, 4, 2, 8}
+ i := 0
+ for {
+ select {
+ case <-stop:
+ return
+ default:
+ }
+ l.setLimits(Limits{Max: caps[i%len(caps)], QueueSize: 32, QueueTimeout: 5 * time.Second})
+ i++
+ time.Sleep(time.Millisecond)
+ }
+ }()
+
+ var wg sync.WaitGroup
+ for i := 0; i < 60; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ r, err := l.Acquire(context.Background(), deadlineIn(5*time.Second))
+ if err != nil {
+ if !errors.Is(err, ErrQueueFull) && !errors.Is(err, ErrQueueTimeout) {
+ t.Errorf("unexpected error: %v", err)
+ }
+ return
+ }
+ time.Sleep(time.Millisecond)
+ r()
+ }()
+ }
+ wg.Wait()
+ close(stop)
+ swapWG.Wait()
+
+ if st := l.Stats(); st.Running != 0 || st.Queued != 0 {
+ t.Fatalf("stats after drain = %+v, want zero", st)
+ }
+}
+
+func waitFor(t *testing.T, timeout time.Duration, cond func() bool) {
+ t.Helper()
+ deadline := time.Now().Add(timeout)
+ for time.Now().Before(deadline) {
+ if cond() {
+ return
+ }
+ time.Sleep(time.Millisecond)
+ }
+ t.Fatalf("condition not met within %v", timeout)
+}
+
+// TestRaisedCapGoesToTheWaiterNotANewcomer pins FIFO across a cap raise.
+//
+// Publishing a raise is necessarily two steps — store the new generation, then
+// re-grant the queue — and the fast path used to admit on free capacity without
+// looking at the queue at all. A call arriving in that window therefore took
+// the slot the raise had just created, in front of a waiter that had been in
+// line for the whole reload.
+//
+// The window is reproduced exactly: the limits are swapped WITHOUT re-granting
+// (which is the state between the two publish steps), and the cap is raised by
+// only one, so there is exactly one new slot and it can go to only one of them.
+func TestRaisedCapGoesToTheWaiterNotANewcomer(t *testing.T) {
+ l := New(ScopeServer, "srv", Limits{Max: 1, QueueSize: 4, QueueTimeout: time.Hour})
+
+ held, err := l.Acquire(context.Background(), deadlineIn(time.Hour))
+ if err != nil {
+ t.Fatalf("first acquire: %v", err)
+ }
+ defer held()
+
+ waiterErr := make(chan error, 1)
+ waiterAdmitted := make(chan struct{})
+ go func() {
+ release, aerr := l.Acquire(context.Background(), deadlineIn(time.Hour))
+ if aerr == nil {
+ close(waiterAdmitted)
+ release()
+ }
+ waiterErr <- aerr
+ }()
+ waitFor(t, time.Second, func() bool { return l.Stats().Queued == 1 })
+
+ // The raise lands, the re-grant has not run yet: one new slot, one waiter,
+ // and a newcomer racing for it. queue_size 0 makes the newcomer's outcome
+ // unambiguous — it either takes the slot or is shed.
+ raised := Limits{Max: 2, QueueSize: 0, QueueTimeout: time.Hour}
+ l.held.Store(&raised)
+
+ _, newcomerErr := l.acquire(context.Background(), raised, deadlineIn(time.Hour))
+ if !errors.Is(newcomerErr, ErrQueueFull) {
+ t.Fatalf("newcomer took the slot the raise created: err = %v, want ErrQueueFull", newcomerErr)
+ }
+
+ select {
+ case <-waiterAdmitted:
+ case <-time.After(2 * time.Second):
+ t.Fatal("the queued call must get the first slot a raise creates (FIFO)")
+ }
+ if aerr := <-waiterErr; aerr != nil {
+ t.Fatalf("queued call: %v", aerr)
+ }
+}
+
+// TestFastPathDoesNotOvertakeAQueue is the same rule in steady state: while
+// anyone is queued, an arriving call joins the back of the line even if its own
+// generation would let it run.
+func TestFastPathDoesNotOvertakeAQueue(t *testing.T) {
+ l := New(ScopeServer, "srv", Limits{Max: 1, QueueSize: 4, QueueTimeout: time.Hour})
+
+ held, err := l.Acquire(context.Background(), deadlineIn(time.Hour))
+ if err != nil {
+ t.Fatalf("first acquire: %v", err)
+ }
+
+ first := make(chan error, 1)
+ go func() {
+ release, aerr := l.Acquire(context.Background(), deadlineIn(time.Hour))
+ if aerr == nil {
+ release()
+ }
+ first <- aerr
+ }()
+ waitFor(t, time.Second, func() bool { return l.Stats().Queued == 1 })
+
+ // A stale generation with a bigger cap must not let this call jump the queue.
+ second := make(chan error, 1)
+ go func() {
+ release, aerr := l.acquire(context.Background(), Limits{Max: 5, QueueSize: 4, QueueTimeout: time.Hour}, deadlineIn(time.Hour))
+ if aerr == nil {
+ release()
+ }
+ second <- aerr
+ }()
+ waitFor(t, time.Second, func() bool { return l.Stats().Queued == 2 })
+
+ if got := l.Stats().Running; got != 1 {
+ t.Fatalf("Running = %d, want 1 — a permissive generation must not overtake the queue", got)
+ }
+
+ held()
+ for i := 0; i < 2; i++ {
+ select {
+ case aerr := <-first:
+ if aerr != nil {
+ t.Fatalf("queued call: %v", aerr)
+ }
+ first = nil
+ case aerr := <-second:
+ if first != nil {
+ t.Fatal("the second caller was served before the first (FIFO violated)")
+ }
+ if aerr != nil {
+ t.Fatalf("second queued call: %v", aerr)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("queued calls did not drain")
+ }
+ }
+}
diff --git a/internal/upstream/limiter/observer.go b/internal/upstream/limiter/observer.go
new file mode 100644
index 000000000..80358e836
--- /dev/null
+++ b/internal/upstream/limiter/observer.go
@@ -0,0 +1,41 @@
+package limiter
+
+import (
+ "context"
+ "time"
+)
+
+// Rejection describes one shed call. It is the payload of the ORIGIN-INDEPENDENT
+// rejection seam required by FR-012/FR-013: the observer is invoked at the
+// admission point inside the managed client, which every in-process dispatch
+// path funnels through (MCP tool-call variants, the REST tool-call endpoint,
+// sandboxed code execution, activity replay). Paths that never reach the MCP
+// dispatch layer therefore still produce a "rejected" activity record and a
+// rejection metric.
+type Rejection struct {
+ // Server is the upstream the call targeted. Always set, even for a global
+ // rejection — the caller-facing MESSAGE must not blame it (FR-010), but the
+ // activity record and the metric label still need to know where the call was
+ // headed.
+ Server string
+ // Tool is the upstream tool name (without the server prefix).
+ Tool string
+ // Scope is the tier that shed the call.
+ Scope Scope
+ // Reason is queue_full or queue_timeout.
+ Reason Reason
+ // Limit is the cap that was in force in the shedding scope.
+ Limit int
+ // RetryAfter is the shedding scope's effective queue_timeout, used as the
+ // REST Retry-After hint (FR-011).
+ RetryAfter time.Duration
+ // Waited is how long the call spent in the queue before being shed (0 for a
+ // queue_full shed, which never waits).
+ Waited time.Duration
+ // Message is the caller-facing explanation (LimitError.Error()).
+ Message string
+}
+
+// Observer receives every shed call. Implementations must not block: they run
+// on the caller's goroutine at the moment of rejection.
+type Observer func(ctx context.Context, rej Rejection)
diff --git a/internal/upstream/limiter/registry.go b/internal/upstream/limiter/registry.go
new file mode 100644
index 000000000..3f8480a36
--- /dev/null
+++ b/internal/upstream/limiter/registry.go
@@ -0,0 +1,410 @@
+package limiter
+
+import (
+ "context"
+ "sync"
+ "sync/atomic"
+ "time"
+)
+
+// defaultQueueBudget is the wait budget used when a scope caps concurrency but
+// publishes no queue_timeout. The config layer already defaults an active scope
+// to 30s, so this only guards limits published directly (API, tests): a queued
+// call must never wait without a deadline, which is the one way a saturated
+// scope could park a caller indefinitely.
+const defaultQueueBudget = 30 * time.Second
+
+// Registry owns the live limiter instances: one per configured upstream server
+// plus the proxy-wide aggregate. Readers take a whole GENERATION through one
+// atomic pointer (no lock on the hot path); writers (config apply / hot reload)
+// serialize on the registry mutex and republish the generation as a unit.
+//
+// Three invariants drive the shape of this type:
+//
+// - FR-021, atomic publication: an admission must never combine one scope's
+// new settings with another scope's old ones, nor a new cap with a queue
+// deadline derived from an older generation. Everything an admission needs
+// — both limiter instances, both scopes' limits, and the resolved wait
+// budget — is resolved from ONE generation load, and the limits themselves
+// live only in generations. Publishing therefore cannot disturb an
+// admission already in flight: the new generation is stored first and the
+// wait queues are re-evaluated against it afterwards.
+//
+// - FR-021, shared occupancy: limiter instances SURVIVE a reload (only the
+// limits are republished), and an instance exists for every eligible scope
+// even when that scope currently caps nothing. An unlimited scope still
+// counts its running calls, so enabling a cap later sees the calls already
+// in flight instead of starting from zero and admitting a second cap's
+// worth on top of them.
+//
+// - FR-009, no admit-after-disable: retiring a server TOMBSTONES its entry
+// and the tombstone is never removed. Absence of a scope means "unlimited",
+// so absence must never be able to follow retirement — a caller that
+// resolved its client before the server was disabled can reach admission at
+// any later time, and it has to be refused, not waved through. Outstanding
+// holds drain into the retired instance; a server re-added under the same
+// name replaces the tombstone with a fresh instance, so the map holds at
+// most one entry per distinct server name the process has ever configured.
+type Registry struct {
+ mu sync.Mutex
+
+ // Live instances, mutated under mu only and snapshotted into each
+ // generation. Includes retired tombstones.
+ global *Limiter
+ servers map[string]*Limiter
+
+ gen atomic.Pointer[generation]
+}
+
+// generation is one immutable published set of limits. Readers load it once per
+// admission, so every value an admission observes belongs to the same publish.
+type generation struct {
+ global *Limiter
+ globalLimits Limits
+ servers map[string]*serverScope
+}
+
+// serverScope is one server's slot in a generation: its limiter instance, the
+// limits published for it, and the wait budget that spans BOTH tiers (FR-004).
+type serverScope struct {
+ lim *Limiter
+ limits Limits
+ budget time.Duration
+}
+
+// NewRegistry returns an empty registry: nothing is published, so every Acquire
+// is a no-op passthrough (FR-006).
+func NewRegistry() *Registry { return &Registry{} }
+
+// Global returns the proxy-wide limiter, or nil when none was published.
+func (r *Registry) Global() *Limiter {
+ if r == nil {
+ return nil
+ }
+ gen := r.gen.Load()
+ if gen == nil {
+ return nil
+ }
+ return gen.global
+}
+
+// Server returns the LIVE limiter for an upstream, or nil when that server has
+// no limiter — unconfigured, or retired. A retired instance is deliberately
+// invisible here (it is a tombstone, not a working scope); admission still
+// finds it internally and refuses the call.
+func (r *Registry) Server(name string) *Limiter {
+ if r == nil {
+ return nil
+ }
+ gen := r.gen.Load()
+ if gen == nil {
+ return nil
+ }
+ scope := gen.servers[name]
+ if scope == nil || scope.lim.Retired() {
+ return nil
+ }
+ return scope.lim
+}
+
+// SetGlobal publishes the global aggregate limiter's settings.
+func (r *Registry) SetGlobal(limits Limits) {
+ if r == nil {
+ return
+ }
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.publishLocked(limits, r.serverLimitsLocked(map[string]Limits{}))
+}
+
+// SetServer publishes one server's limits and returns the live instance.
+func (r *Registry) SetServer(name string, limits Limits) *Limiter {
+ if r == nil {
+ return nil
+ }
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ lim := r.ensureServerLocked(name)
+ r.publishLocked(r.globalLimitsLocked(), r.serverLimitsLocked(map[string]Limits{name: limits}))
+ return lim
+}
+
+// RetireServer tombstones a server's limiter: queued calls fail immediately
+// with ErrServerUnavailable and later admissions are refused, even if the
+// caller snapshotted the client before the state change (FR-009,
+// admit-after-disable race). Outstanding holds drain into the retired instance.
+func (r *Registry) RetireServer(name string) {
+ if r == nil {
+ return
+ }
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.retireServerLocked(name)
+ r.publishLocked(r.globalLimitsLocked(), r.serverLimitsLocked(nil))
+}
+
+// Apply publishes one atomic generation of limits for every scope (FR-021):
+// the global aggregate plus the resolved per-server limits. Servers missing
+// from the map are retired (their tombstone stays, see the type comment).
+func (r *Registry) Apply(global Limits, servers map[string]Limits) {
+ if r == nil {
+ return
+ }
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ for name := range r.servers {
+ if _, ok := servers[name]; !ok {
+ r.retireServerLocked(name)
+ }
+ }
+ for name := range servers {
+ r.ensureServerLocked(name)
+ }
+
+ r.publishLocked(global, servers)
+}
+
+// ensureGlobalLocked creates the global instance if it does not exist yet. The
+// instance exists even when the scope caps nothing: an unlimited scope is still
+// an occupancy tracker (FR-021).
+func (r *Registry) ensureGlobalLocked() {
+ if r.global != nil {
+ return
+ }
+ r.global = newPublished(ScopeGlobal, "", func() Limits {
+ gen := r.gen.Load()
+ if gen == nil {
+ return Limits{}
+ }
+ return gen.globalLimits
+ })
+}
+
+// ensureServerLocked creates a server's instance if it has none, or replaces a
+// RETIRED one with a fresh instance: the tombstone's outstanding holds belong
+// to it alone, so a re-added server never inherits them (FR-009).
+func (r *Registry) ensureServerLocked(name string) *Limiter {
+ if r.servers == nil {
+ r.servers = make(map[string]*Limiter)
+ }
+ if cur := r.servers[name]; cur != nil && !cur.Retired() {
+ return cur
+ }
+ fresh := newPublished(ScopeServer, name, func() Limits {
+ gen := r.gen.Load()
+ if gen == nil {
+ return Limits{}
+ }
+ scope := gen.servers[name]
+ if scope == nil {
+ return Limits{}
+ }
+ return scope.limits
+ })
+ r.servers[name] = fresh
+ return fresh
+}
+
+func (r *Registry) retireServerLocked(name string) {
+ cur := r.servers[name]
+ if cur == nil || cur.Retired() {
+ return
+ }
+ cur.Retire()
+}
+
+// globalLimitsLocked returns the limits currently published for the global
+// scope, so a single-scope update can republish the rest unchanged.
+func (r *Registry) globalLimitsLocked() Limits {
+ gen := r.gen.Load()
+ if gen == nil {
+ return Limits{}
+ }
+ return gen.globalLimits
+}
+
+// serverLimitsLocked returns the currently published per-server limits with
+// `overrides` applied, so a single-scope update carries every other scope
+// forward untouched into the new generation.
+func (r *Registry) serverLimitsLocked(overrides map[string]Limits) map[string]Limits {
+ out := make(map[string]Limits, len(r.servers))
+ if gen := r.gen.Load(); gen != nil {
+ for name, scope := range gen.servers {
+ out[name] = scope.limits
+ }
+ }
+ for name := range r.servers {
+ if _, ok := out[name]; !ok {
+ out[name] = Limits{}
+ }
+ }
+ for name, limits := range overrides {
+ out[name] = limits
+ }
+ return out
+}
+
+// publishLocked snapshots the live instances plus the given limits into one
+// immutable generation, stores it, and only THEN re-evaluates every wait queue
+// against it. Callers must hold r.mu.
+//
+// The order matters in both directions. Storing first means no admission can
+// ever see a limiter whose cap has moved out from under the generation it
+// resolved — there is no per-instance limits state to move. Re-granting after
+// means a raise admits eligible waiters immediately and a lowered cap admits
+// nothing until occupancy drains, which is FR-021's "takes effect within one
+// reload cycle" in both directions.
+func (r *Registry) publishLocked(global Limits, servers map[string]Limits) {
+ r.ensureGlobalLocked()
+
+ gen := &generation{global: r.global, globalLimits: global, servers: make(map[string]*serverScope, len(r.servers))}
+ for name, lim := range r.servers {
+ limits := servers[name] // absent from the new config = retired, no limits
+ gen.servers[name] = &serverScope{
+ lim: lim,
+ limits: limits,
+ budget: queueBudget(limits, global),
+ }
+ }
+ r.gen.Store(gen)
+
+ // Re-evaluate the queues against what was just published.
+ r.global.regrant()
+ for _, lim := range r.servers {
+ lim.regrant()
+ }
+}
+
+// QueueBudget reports the wait budget an admission for this server would get
+// from the currently published generation. It is the single owner of the FR-004
+// rule (the config layer resolves per-scope settings; combining them into one
+// deadline happens here, where the generation guarantees both scopes come from
+// the same publish).
+func (r *Registry) QueueBudget(server string) time.Duration {
+ if r == nil {
+ return 0
+ }
+ gen := r.gen.Load()
+ if gen == nil {
+ return 0
+ }
+ if scope := gen.servers[server]; scope != nil {
+ return scope.budget
+ }
+ return queueBudget(Limits{}, gen.globalLimits)
+}
+
+// effectiveQueueTimeout is one scope's wait budget: its configured
+// queue_timeout, or the fallback when it caps concurrency without naming one. A
+// scope that caps nothing has no budget — it never makes a call wait.
+//
+// Resolving this PER SCOPE matters. Folding the fallback in at the end instead
+// let a capped scope with no timeout of its own contribute nothing whenever the
+// other scope named one, so a server capped with no timeout alongside a global
+// 60s inherited 60s rather than its own 30s.
+func effectiveQueueTimeout(l Limits) time.Duration {
+ if !l.Enabled() {
+ return 0
+ }
+ if l.QueueTimeout > 0 {
+ return l.QueueTimeout
+ }
+ return defaultQueueBudget
+}
+
+// queueBudget is the total wait budget for one call: the smallest effective
+// queue_timeout among the scopes that actually limit it (FR-004 — one absolute
+// deadline spanning the per-server and global admission steps combined, never
+// one budget per step). 0 means no limiter applies, so there is nothing to wait
+// for; a scope that DOES limit always yields a positive budget, so a queued
+// call can never sit without a deadline.
+func queueBudget(server, global Limits) time.Duration {
+ budget := time.Duration(0)
+ for _, timeout := range [...]time.Duration{effectiveQueueTimeout(server), effectiveQueueTimeout(global)} {
+ if timeout <= 0 {
+ continue
+ }
+ if budget == 0 || timeout < budget {
+ budget = timeout
+ }
+ }
+ return budget
+}
+
+// Acquire admits a call through both tiers of ONE published generation: the
+// same generation supplies both limiters, both scopes' limits — which are what
+// the admission decision itself is made against — and the single absolute queue
+// deadline (FR-004, FR-021). The per-server slot is taken first so a slow
+// upstream's queue does not pin global capacity while waiting (FR-002); on a
+// global rejection the per-server slot is released again.
+//
+// The returned release closure releases both tiers and is safe to call more
+// than once.
+func (r *Registry) Acquire(ctx context.Context, server string) (func(), error) {
+ if r == nil {
+ return noopRelease, nil
+ }
+ return r.acquireIn(r.gen.Load(), ctx, server)
+}
+
+// acquireIn is Acquire against an explicitly chosen generation. Production
+// always passes the current one; taking it as a parameter is what makes "one
+// generation governs one admission" testable, by letting a test hold a
+// generation across a reload.
+func (r *Registry) acquireIn(gen *generation, ctx context.Context, server string) (func(), error) {
+ if gen == nil {
+ return noopRelease, nil
+ }
+
+ var (
+ serverLim *Limiter
+ serverLimits Limits
+ budget time.Duration
+ )
+ if scope := gen.servers[server]; scope != nil {
+ serverLim, serverLimits, budget = scope.lim, scope.limits, scope.budget
+ } else {
+ budget = queueBudget(Limits{}, gen.globalLimits)
+ }
+
+ var deadline time.Time
+ if budget > 0 {
+ deadline = time.Now().Add(budget)
+ }
+
+ releaseServer, err := serverLim.acquire(ctx, serverLimits, deadline)
+ if err != nil {
+ return nil, err
+ }
+ releaseGlobal, err := gen.global.acquire(ctx, gen.globalLimits, deadline)
+ if err != nil {
+ releaseServer()
+ return nil, err
+ }
+ return func() {
+ releaseGlobal()
+ releaseServer()
+ }, nil
+}
+
+// ServerStats returns the occupancy of every live per-server limiter, keyed by
+// server name (FR-013: per-server queue depth for the metrics surface). Retired
+// tombstones are omitted: they belong to servers that are no longer configured.
+func (r *Registry) ServerStats() map[string]Stats {
+ if r == nil {
+ return nil
+ }
+ gen := r.gen.Load()
+ if gen == nil {
+ return nil
+ }
+ out := make(map[string]Stats, len(gen.servers))
+ for name, scope := range gen.servers {
+ if scope.lim.Retired() {
+ continue
+ }
+ out[name] = scope.lim.Stats()
+ }
+ return out
+}
diff --git a/internal/upstream/limiter/registry_test.go b/internal/upstream/limiter/registry_test.go
new file mode 100644
index 000000000..0c153603f
--- /dev/null
+++ b/internal/upstream/limiter/registry_test.go
@@ -0,0 +1,602 @@
+package limiter
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+func TestRegistryZeroConfigIsNoOp(t *testing.T) {
+ r := NewRegistry()
+
+ if r.Server("anything") != nil {
+ t.Fatal("unconfigured server must have no limiter instance")
+ }
+ if r.Global() != nil {
+ t.Fatal("unconfigured global scope must have no limiter instance")
+ }
+
+ release, err := r.Acquire(context.Background(), "anything")
+ if err != nil {
+ t.Fatalf("zero-config Acquire: %v", err)
+ }
+ if release == nil {
+ t.Fatal("zero-config Acquire returned nil release")
+ }
+ release()
+}
+
+func TestRegistryApplyBuildsAndUpdatesScopes(t *testing.T) {
+ r := NewRegistry()
+ r.Apply(Limits{Max: 10, QueueSize: 5, QueueTimeout: time.Second},
+ map[string]Limits{"a": {Max: 1, QueueSize: 1, QueueTimeout: 2 * time.Second}})
+
+ if r.Global() == nil {
+ t.Fatal("global limiter not created")
+ }
+ a := r.Server("a")
+ if a == nil {
+ t.Fatal("server limiter not created")
+ }
+ if got := a.Limits(); got.Max != 1 || got.QueueSize != 1 || got.QueueTimeout != 2*time.Second {
+ t.Fatalf("server limits = %+v", got)
+ }
+
+ // Re-apply with different values: the SAME instance must be updated so
+ // occupancy is shared across generations (FR-021).
+ rel, err := a.Acquire(context.Background(), deadlineIn(time.Second))
+ if err != nil {
+ t.Fatalf("acquire: %v", err)
+ }
+ r.Apply(Limits{Max: 20}, map[string]Limits{"a": {Max: 3, QueueSize: 2, QueueTimeout: time.Second}})
+ if r.Server("a") != a {
+ t.Fatal("hot-reload replaced the limiter instance; occupancy would be lost")
+ }
+ if got := a.Stats().Running; got != 1 {
+ t.Fatalf("Running after hot-reload = %d, want 1 (occupancy shared across generations)", got)
+ }
+ if got := a.Limits().Max; got != 3 {
+ t.Fatalf("Max after hot-reload = %d, want 3", got)
+ }
+ rel()
+}
+
+func TestRegistryApplyRetiresRemovedServers(t *testing.T) {
+ r := NewRegistry()
+ r.Apply(Limits{}, map[string]Limits{"gone": {Max: 1, QueueSize: 4, QueueTimeout: time.Hour}})
+
+ gone := r.Server("gone")
+ rel, err := gone.Acquire(context.Background(), deadlineIn(time.Hour))
+ if err != nil {
+ t.Fatalf("acquire: %v", err)
+ }
+
+ errCh := make(chan error, 1)
+ go func() {
+ _, err := gone.Acquire(context.Background(), deadlineIn(time.Hour))
+ errCh <- err
+ }()
+ waitFor(t, time.Second, func() bool { return gone.Stats().Queued == 1 })
+
+ // Server removed from the config: its queued calls must fail promptly.
+ r.Apply(Limits{}, map[string]Limits{})
+
+ select {
+ case err := <-errCh:
+ if !errors.Is(err, ErrServerUnavailable) {
+ t.Fatalf("queued call after removal: %v, want ErrServerUnavailable", err)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("removal did not promptly fail the queued call")
+ }
+ if r.Server("gone") != nil {
+ t.Fatal("removed server still has a live limiter")
+ }
+
+ // The retired instance keeps draining the outstanding hold safely.
+ rel()
+ if got := gone.Stats().Running; got != 0 {
+ t.Fatalf("retired instance Running = %d, want 0 after drain", got)
+ }
+}
+
+func TestRetiredHoldsDoNotDoubleCountAgainstReAddedServer(t *testing.T) {
+ r := NewRegistry()
+ r.Apply(Limits{}, map[string]Limits{"s": {Max: 1, QueueSize: 1, QueueTimeout: time.Second}})
+
+ old := r.Server("s")
+ rel, err := old.Acquire(context.Background(), deadlineIn(time.Second))
+ if err != nil {
+ t.Fatalf("acquire: %v", err)
+ }
+
+ r.RetireServer("s")
+ r.Apply(Limits{}, map[string]Limits{"s": {Max: 1, QueueSize: 1, QueueTimeout: time.Second}})
+
+ fresh := r.Server("s")
+ if fresh == nil || fresh == old {
+ t.Fatal("re-added server must get a fresh limiter instance")
+ }
+ if got := fresh.Stats().Running; got != 0 {
+ t.Fatalf("fresh instance Running = %d, want 0 (old holds belong to the retired instance)", got)
+ }
+
+ // The fresh instance has its full capacity available immediately.
+ rel2, err := fresh.Acquire(context.Background(), deadlineIn(time.Second))
+ if err != nil {
+ t.Fatalf("fresh acquire: %v", err)
+ }
+ rel2()
+
+ // Releasing the old hold drains the retired instance only.
+ rel()
+ if got := old.Stats().Running; got != 0 {
+ t.Fatalf("retired instance Running = %d, want 0", got)
+ }
+ if got := fresh.Stats().Running; got != 0 {
+ t.Fatalf("fresh instance Running = %d after old release, want 0", got)
+ }
+}
+
+// TestAdmissionAfterRetireIsRefused is the FR-009 admit-after-disable guard at
+// the REGISTRY level. Deleting the entry on retirement made a later admission
+// see "no limiter for this server" and pass straight through; the tombstone
+// makes it fail with the server-unavailable semantics instead.
+func TestAdmissionAfterRetireIsRefused(t *testing.T) {
+ r := NewRegistry()
+ r.Apply(Limits{Max: 8, QueueSize: 8, QueueTimeout: time.Second},
+ map[string]Limits{"s": {Max: 2, QueueSize: 2, QueueTimeout: time.Second}})
+
+ r.RetireServer("s")
+
+ _, err := r.Acquire(context.Background(), "s")
+ if !errors.Is(err, ErrServerUnavailable) {
+ t.Fatalf("admission after retirement: %v, want ErrServerUnavailable", err)
+ }
+ if r.Server("s") != nil {
+ t.Fatal("a retired server must not expose a live limiter")
+ }
+ if _, ok := r.ServerStats()["s"]; ok {
+ t.Fatal("a retired server must not report occupancy")
+ }
+ // The global tier must not have taken a slot for the refused call.
+ if got := r.Global().Stats().Running; got != 0 {
+ t.Fatalf("global Running = %d, want 0", got)
+ }
+}
+
+// TestUnconfiguredScopesStillCountOccupancy is the FR-021 shared-occupancy
+// guard: a scope with no cap is still an occupancy tracker, so hot-enabling a
+// cap admits nothing until the grandfathered calls drain.
+func TestUnconfiguredScopesStillCountOccupancy(t *testing.T) {
+ r := NewRegistry()
+ r.Apply(Limits{}, map[string]Limits{"s": {}})
+
+ rel1, err := r.Acquire(context.Background(), "s")
+ if err != nil {
+ t.Fatalf("unlimited acquire: %v", err)
+ }
+ rel2, err := r.Acquire(context.Background(), "s")
+ if err != nil {
+ t.Fatalf("unlimited acquire: %v", err)
+ }
+ if got := r.Server("s").Stats().Running; got != 2 {
+ t.Fatalf("Running = %d, want 2 (an unlimited scope still counts)", got)
+ }
+ if got := r.Global().Stats().Running; got != 2 {
+ t.Fatalf("global Running = %d, want 2", got)
+ }
+
+ // Cap enabled below the live occupancy.
+ r.Apply(Limits{}, map[string]Limits{"s": {Max: 1, QueueSize: 0, QueueTimeout: time.Second}})
+ if _, err := r.Acquire(context.Background(), "s"); !errors.Is(err, ErrQueueFull) {
+ t.Fatalf("admission over a freshly enabled cap: %v, want ErrQueueFull", err)
+ }
+
+ rel1()
+ rel2()
+ rel3, err := r.Acquire(context.Background(), "s")
+ if err != nil {
+ t.Fatalf("acquire after drain: %v", err)
+ }
+ rel3()
+}
+
+// TestAcquireObservesOneGeneration is the FR-021 atomic-publication guard. Every
+// generation ties its cap to a queue timeout of cap × 10ms, so a rejection that
+// reports limit N with a Retry-After other than N × 10ms proves the admission
+// combined values from two different publications (or a cap from one generation
+// with a queue deadline from another).
+func TestAcquireObservesOneGeneration(t *testing.T) {
+ r := NewRegistry()
+ publish := func(n int) {
+ limits := Limits{Max: n, QueueSize: 2, QueueTimeout: time.Duration(n) * 10 * time.Millisecond}
+ r.Apply(limits, map[string]Limits{"a": limits})
+ }
+ publish(1)
+
+ stop := make(chan struct{})
+ var applyWG sync.WaitGroup
+ applyWG.Add(1)
+ go func() {
+ defer applyWG.Done()
+ for i := 0; ; i++ {
+ select {
+ case <-stop:
+ return
+ default:
+ }
+ publish(1 + i%4)
+ }
+ }()
+
+ // A published generation is internally consistent by construction: both
+ // scopes' limits and the wait budget always come from the same publish.
+ // This is the property Acquire relies on by loading the generation once.
+ var readerWG sync.WaitGroup
+ readerWG.Add(1)
+ go func() {
+ defer readerWG.Done()
+ for {
+ select {
+ case <-stop:
+ return
+ default:
+ }
+ gen := r.gen.Load()
+ scope := gen.servers["a"]
+ if scope.limits.Max != gen.globalLimits.Max ||
+ scope.budget != time.Duration(scope.limits.Max)*10*time.Millisecond {
+ t.Errorf("generation mixes publications: global=%+v server=%+v budget=%v",
+ gen.globalLimits, scope.limits, scope.budget)
+ return
+ }
+ }
+ }()
+
+ // Occupancy is shared across generations, so however the caps move, the
+ // number of calls running at once can never exceed the largest cap ever
+ // published. An admission that mixed a stale cap with live occupancy would
+ // break this.
+ var running, peak int64
+
+ var wg sync.WaitGroup
+ for i := 0; i < 64; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ rel, err := r.Acquire(context.Background(), "a")
+ if err != nil {
+ var le *LimitError
+ if !errors.As(err, &le) {
+ t.Errorf("unexpected error: %v", err)
+ return
+ }
+ if want := time.Duration(le.Limit) * 10 * time.Millisecond; le.RetryAfter != want {
+ t.Errorf("mixed generation: limit %d reported with Retry-After %v, want %v",
+ le.Limit, le.RetryAfter, want)
+ }
+ return
+ }
+ cur := atomic.AddInt64(&running, 1)
+ for {
+ old := atomic.LoadInt64(&peak)
+ if cur <= old || atomic.CompareAndSwapInt64(&peak, old, cur) {
+ break
+ }
+ }
+ time.Sleep(time.Millisecond)
+ atomic.AddInt64(&running, -1)
+ rel()
+ }()
+ }
+ wg.Wait()
+ if peak > 4 {
+ t.Fatalf("peak concurrency = %d, want <= 4 (the largest cap ever published)", peak)
+ }
+ close(stop)
+ applyWG.Wait()
+ readerWG.Wait()
+
+ if st := r.Server("a").Stats(); st.Running != 0 || st.Queued != 0 {
+ t.Fatalf("server stats after drain = %+v", st)
+ }
+ if st := r.Global().Stats(); st.Running != 0 || st.Queued != 0 {
+ t.Fatalf("global stats after drain = %+v", st)
+ }
+}
+
+func TestRegistryAcquireAcquiresServerBeforeGlobal(t *testing.T) {
+ r := NewRegistry()
+ r.Apply(Limits{Max: 1, QueueSize: 0, QueueTimeout: time.Second},
+ map[string]Limits{"a": {Max: 1, QueueSize: 4, QueueTimeout: time.Second}})
+
+ rel, err := r.Acquire(context.Background(), "a")
+ if err != nil {
+ t.Fatalf("acquire: %v", err)
+ }
+ if got := r.Global().Stats().Running; got != 1 {
+ t.Fatalf("global Running = %d, want 1", got)
+ }
+ if got := r.Server("a").Stats().Running; got != 1 {
+ t.Fatalf("server Running = %d, want 1", got)
+ }
+ rel()
+ if got := r.Global().Stats().Running; got != 0 {
+ t.Fatalf("global Running after release = %d, want 0", got)
+ }
+ if got := r.Server("a").Stats().Running; got != 0 {
+ t.Fatalf("server Running after release = %d, want 0", got)
+ }
+}
+
+func TestRegistryAcquireReleasesServerSlotWhenGlobalSheds(t *testing.T) {
+ r := NewRegistry()
+ r.Apply(Limits{Max: 1, QueueSize: 0, QueueTimeout: time.Second},
+ map[string]Limits{"a": {Max: 4, QueueSize: 4, QueueTimeout: time.Second}, "b": {Max: 4, QueueSize: 4, QueueTimeout: time.Second}})
+
+ relA, err := r.Acquire(context.Background(), "a")
+ if err != nil {
+ t.Fatalf("acquire a: %v", err)
+ }
+ defer relA()
+
+ _, err = r.Acquire(context.Background(), "b")
+ if !errors.Is(err, ErrQueueFull) {
+ t.Fatalf("expected global shed, got %v", err)
+ }
+ var le *LimitError
+ if !errors.As(err, &le) || le.Scope != ScopeGlobal {
+ t.Fatalf("expected a global-scope LimitError, got %v", err)
+ }
+ if got := r.Server("b").Stats().Running; got != 0 {
+ t.Fatalf("server b Running = %d, want 0 (the per-server slot must be released when global sheds)", got)
+ }
+}
+
+func TestRegistryGlobalScopeMessageDoesNotBlameServer(t *testing.T) {
+ err := &LimitError{Scope: ScopeGlobal, Reason: ReasonQueueFull, Limit: 4, RetryAfter: time.Second}
+ msg := err.Error()
+ if strings.Contains(msg, "server") {
+ t.Fatalf("global-scope message must not blame a server: %q", msg)
+ }
+ if !strings.Contains(msg, "retry") {
+ t.Fatalf("shed message must advise retrying: %q", msg)
+ }
+
+ srvErr := &LimitError{Scope: ScopeServer, Server: "github", Reason: ReasonQueueTimeout, Limit: 2, RetryAfter: 30 * time.Second}
+ if !strings.Contains(srvErr.Error(), "github") {
+ t.Fatalf("server-scope message must name the server: %q", srvErr.Error())
+ }
+}
+
+func TestRegistryConcurrentApplyAndAcquire(t *testing.T) {
+ r := NewRegistry()
+ r.Apply(Limits{Max: 4, QueueSize: 16, QueueTimeout: 5 * time.Second},
+ map[string]Limits{"a": {Max: 2, QueueSize: 16, QueueTimeout: 5 * time.Second}})
+
+ stop := make(chan struct{})
+ var applyWG sync.WaitGroup
+ applyWG.Add(1)
+ go func() {
+ defer applyWG.Done()
+ for i := 0; ; i++ {
+ select {
+ case <-stop:
+ return
+ default:
+ }
+ maxN := 1 + i%4
+ r.Apply(Limits{Max: maxN, QueueSize: 16, QueueTimeout: 5 * time.Second},
+ map[string]Limits{"a": {Max: maxN, QueueSize: 16, QueueTimeout: 5 * time.Second}})
+ time.Sleep(time.Millisecond)
+ }
+ }()
+
+ var wg sync.WaitGroup
+ for i := 0; i < 50; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ rel, err := r.Acquire(context.Background(), "a")
+ if err != nil {
+ if !errors.Is(err, ErrQueueFull) && !errors.Is(err, ErrQueueTimeout) {
+ t.Errorf("unexpected error: %v", err)
+ }
+ return
+ }
+ time.Sleep(time.Millisecond)
+ rel()
+ }()
+ }
+ wg.Wait()
+ close(stop)
+ applyWG.Wait()
+
+ if st := r.Server("a").Stats(); st.Running != 0 || st.Queued != 0 {
+ t.Fatalf("server stats after drain = %+v", st)
+ }
+ if st := r.Global().Stats(); st.Running != 0 || st.Queued != 0 {
+ t.Fatalf("global stats after drain = %+v", st)
+ }
+}
+
+func TestRegistryDisabledScopeKeepsInstanceForOccupancySharing(t *testing.T) {
+ r := NewRegistry()
+ r.Apply(Limits{}, map[string]Limits{"a": {Max: 2, QueueSize: 2, QueueTimeout: time.Second}})
+ a := r.Server("a")
+ rel, err := a.Acquire(context.Background(), deadlineIn(time.Second))
+ if err != nil {
+ t.Fatalf("acquire: %v", err)
+ }
+
+ // Limit disabled at runtime: the instance stays so a later re-enable sees
+ // the still-running call in its occupancy.
+ r.Apply(Limits{}, map[string]Limits{"a": {Max: 0}})
+ if r.Server("a") != a {
+ t.Fatal("disabling a limit must not discard the instance while calls are running")
+ }
+ if got := a.Stats().Running; got != 1 {
+ t.Fatalf("Running = %d, want 1", got)
+ }
+ // Disabled = pure passthrough.
+ rel2, err := a.Acquire(context.Background(), deadlineIn(time.Second))
+ if err != nil {
+ t.Fatalf("acquire on disabled limiter: %v", err)
+ }
+ rel2()
+ rel()
+}
+
+// TestAdmissionIsGovernedByTheGenerationItResolved is the FR-021 atomicity
+// regression test, and it reproduces the exact hang the old code allowed.
+//
+// Limits used to live on the limiter instance and Apply mutated them BEFORE
+// publishing the generation. An admission that had already resolved an
+// UNCAPPED generation — budget 0, because nothing limits it — then made its
+// decision against the freshly mutated cap, found the scope saturated, and
+// parked in the queue with NO deadline. It could only be released by the
+// caller's context, which for an agent call may never end.
+func TestAdmissionIsGovernedByTheGenerationItResolved(t *testing.T) {
+ r := NewRegistry()
+ r.Apply(Limits{}, map[string]Limits{"s": {}})
+
+ uncapped := r.gen.Load()
+ if got := uncapped.servers["s"].budget; got != 0 {
+ t.Fatalf("an uncapped generation must have no wait budget, got %v", got)
+ }
+
+ // Occupy the scope, so a CAPPED generation would have to queue the call below.
+ busy, err := r.Acquire(context.Background(), "s")
+ if err != nil {
+ t.Fatalf("unlimited acquire: %v", err)
+ }
+ defer busy()
+
+ // Reload to a cap of 1 while the call above still holds the scope.
+ r.Apply(Limits{}, map[string]Limits{"s": {Max: 1, QueueSize: 4, QueueTimeout: 30 * time.Second}})
+
+ done := make(chan error, 1)
+ go func() {
+ release, aerr := r.acquireIn(uncapped, context.Background(), "s")
+ if aerr == nil {
+ release()
+ }
+ done <- aerr
+ }()
+
+ select {
+ case aerr := <-done:
+ if aerr != nil {
+ t.Fatalf("an admission resolved against an uncapped generation must be admitted: %v", aerr)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("admission queued against a cap its generation never published (it would wait with no deadline)")
+ }
+}
+
+// TestQueueBudgetResolvesEachScopeIndependently is the FR-004 budget rule. Each
+// ACTIVE scope contributes its own effective timeout — its configured value, or
+// the fallback when it caps concurrency without naming one — and the shared
+// deadline is the smallest of them.
+//
+// Applying the fallback only at the end instead made a capped scope with no
+// timeout of its own contribute NOTHING whenever the other scope named one, so
+// a server capped with no timeout next to a global 60s inherited 60s.
+func TestQueueBudgetResolvesEachScopeIndependently(t *testing.T) {
+ cases := []struct {
+ name string
+ server Limits
+ global Limits
+ want time.Duration
+ }{
+ {"nothing capped", Limits{}, Limits{}, 0},
+ {"server only", Limits{Max: 1, QueueTimeout: 4 * time.Second}, Limits{}, 4 * time.Second},
+ {"global only", Limits{}, Limits{Max: 1, QueueTimeout: 7 * time.Second}, 7 * time.Second},
+ {"both named, smallest wins", Limits{Max: 1, QueueTimeout: 20 * time.Second}, Limits{Max: 1, QueueTimeout: 4 * time.Second}, 4 * time.Second},
+ {"server capped without a timeout", Limits{Max: 1}, Limits{}, defaultQueueBudget},
+ {"global capped without a timeout", Limits{}, Limits{Max: 1}, defaultQueueBudget},
+ {"server fallback beats a larger global", Limits{Max: 1}, Limits{Max: 1, QueueTimeout: 60 * time.Second}, defaultQueueBudget},
+ {"global fallback beats a larger server", Limits{Max: 1, QueueTimeout: 60 * time.Second}, Limits{Max: 1}, defaultQueueBudget},
+ {"named value smaller than the fallback wins", Limits{Max: 1, QueueTimeout: 5 * time.Second}, Limits{Max: 1}, 5 * time.Second},
+ {"an uncapped scope contributes nothing", Limits{QueueTimeout: time.Second}, Limits{Max: 1, QueueTimeout: 9 * time.Second}, 9 * time.Second},
+ {"both capped without timeouts", Limits{Max: 1}, Limits{Max: 2}, defaultQueueBudget},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := queueBudget(tc.server, tc.global); got != tc.want {
+ t.Fatalf("queueBudget(%+v, %+v) = %v, want %v", tc.server, tc.global, got, tc.want)
+ }
+ })
+ }
+}
+
+// TestShedReportsTheScopesEffectiveTimeout covers the other half: the
+// Retry-After a shed advertises is the shedding scope's EFFECTIVE timeout, so a
+// scope capped without a named timeout reports the fallback it actually makes
+// callers wait rather than 0 (which the REST surface would render as 1 second).
+func TestShedReportsTheScopesEffectiveTimeout(t *testing.T) {
+ r := NewRegistry()
+ r.Apply(Limits{Max: 1, QueueTimeout: 60 * time.Second}, map[string]Limits{"s": {Max: 1}})
+
+ if got := r.QueueBudget("s"); got != defaultQueueBudget {
+ t.Fatalf("QueueBudget = %v, want the %v fallback (the server scope caps without a timeout)", got, defaultQueueBudget)
+ }
+
+ held, err := r.Acquire(context.Background(), "s")
+ if err != nil {
+ t.Fatalf("first acquire: %v", err)
+ }
+ defer held()
+
+ _, err = r.Acquire(context.Background(), "s")
+ var le *LimitError
+ if !errors.As(err, &le) {
+ t.Fatalf("expected a shed, got %v", err)
+ }
+ if le.Scope != ScopeServer {
+ t.Fatalf("scope = %s, want server", le.Scope)
+ }
+ if le.RetryAfter != defaultQueueBudget {
+ t.Fatalf("RetryAfter = %v, want the %v the scope actually makes callers wait", le.RetryAfter, defaultQueueBudget)
+ }
+}
+
+// TestSnapshotHolderCannotAdmitAfterRetirement is the FR-009 tombstone-lifetime
+// test. Absence of a scope means "unlimited", so absence must never follow
+// retirement: a caller that resolved its client (and its scope) before the
+// server was disabled can reach admission arbitrarily later, and pruning the
+// drained tombstone in the meantime turned its refusal into a free pass.
+func TestSnapshotHolderCannotAdmitAfterRetirement(t *testing.T) {
+ r := NewRegistry()
+ r.Apply(Limits{}, map[string]Limits{"s": {Max: 2, QueueSize: 2, QueueTimeout: time.Second}})
+
+ // The caller resolves its scope here, then stalls (in production: between
+ // the managed client's IsConnected check and acquireAdmission).
+ snapshot := r.gen.Load()
+
+ r.RetireServer("s")
+ // Reload cycles that used to prune the drained tombstone.
+ for i := 0; i < 3; i++ {
+ r.Apply(Limits{}, map[string]Limits{})
+ }
+
+ // The stalled caller finally admits — against the generation it resolved...
+ if _, err := r.acquireIn(snapshot, context.Background(), "s"); !errors.Is(err, ErrServerUnavailable) {
+ t.Fatalf("snapshot-holding admission after retirement: %v, want ErrServerUnavailable", err)
+ }
+ // ...and a fresh caller against the current one.
+ if _, err := r.Acquire(context.Background(), "s"); !errors.Is(err, ErrServerUnavailable) {
+ t.Fatalf("admission after retirement: %v, want ErrServerUnavailable", err)
+ }
+ if r.gen.Load().servers["s"] == nil {
+ t.Fatal("the tombstone must survive every reload: absence would read as unlimited")
+ }
+ if _, ok := r.ServerStats()["s"]; ok {
+ t.Fatal("a tombstone must not report occupancy")
+ }
+}
diff --git a/internal/upstream/managed/admission.go b/internal/upstream/managed/admission.go
new file mode 100644
index 000000000..70e91830c
--- /dev/null
+++ b/internal/upstream/managed/admission.go
@@ -0,0 +1,102 @@
+package managed
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/upstream/limiter"
+)
+
+// admissionControl is the concurrency-limit wiring the manager hands down to
+// every managed client. It is swapped atomically (never mutated) so the
+// hot-reload path can republish it without a lock.
+type admissionControl struct {
+ registry *limiter.Registry
+ observe limiter.Observer
+}
+
+// SetAdmissionControl installs the limiter registry and the rejection observer
+// on this client. The managed client is the single choke point every in-process
+// upstream tool call passes through (spec 093, Option A), so admission here
+// covers the MCP tool-call variants, the REST tool-call endpoint, sandboxed
+// code execution and activity replay alike (FR-003).
+//
+// Passing a nil registry disables admission for this client (the zero-config
+// default, FR-006).
+func (mc *Client) SetAdmissionControl(registry *limiter.Registry, observe limiter.Observer) {
+ if mc == nil {
+ return
+ }
+ if registry == nil && observe == nil {
+ mc.admission.Store(nil)
+ return
+ }
+ mc.admission.Store(&admissionControl{registry: registry, observe: observe})
+}
+
+// noopRelease is the release closure for a call that took no limiter slot.
+func noopRelease() {}
+
+// acquireAdmission admits one tool call through the per-server and global
+// limiter tiers under ONE absolute queue deadline (FR-004).
+//
+// It runs BEFORE coreClient.CallTool, which is where the call_tool_timeout
+// execution context is created — so queue waiting never eats the execution
+// budget (FR-005). The caller's own context is still honoured while queued:
+// a cancellation is reported as a cancellation, not as shedding.
+//
+// ListTools and the health-check Ping deliberately do NOT go through here
+// (FR-007): they are coalesced/lightweight and must never be able to deadlock
+// behind a saturated tool-call queue.
+func (mc *Client) acquireAdmission(ctx context.Context, toolName string) (func(), error) {
+ adm := mc.admission.Load()
+ if adm == nil || adm.registry == nil {
+ return noopRelease, nil
+ }
+
+ serverCfg := mc.GetConfig()
+ serverName := ""
+ if serverCfg != nil {
+ serverName = serverCfg.Name
+ }
+
+ // The registry resolves everything this admission needs — both limiter
+ // tiers and the ONE absolute queue deadline spanning them (FR-004) — from a
+ // single published generation, so no value here can come from a different
+ // generation than the caps it is applied to (FR-021).
+ start := time.Now()
+ release, err := adm.registry.Acquire(ctx, serverName)
+ if err != nil {
+ mc.reportRejection(ctx, adm, serverName, toolName, time.Since(start), err)
+ return nil, err
+ }
+ return release, nil
+}
+
+// reportRejection forwards a shed to the origin-independent observer. Only
+// queue_full / queue_timeout are sheds; server_unavailable is the existing
+// "server went away" semantics (FR-009) and is reported through the normal
+// error path, not as a rejection.
+func (mc *Client) reportRejection(ctx context.Context, adm *admissionControl, serverName, toolName string, waited time.Duration, err error) {
+ if adm.observe == nil {
+ return
+ }
+ var limitErr *limiter.LimitError
+ if !errors.As(err, &limitErr) {
+ return
+ }
+ if limitErr.Reason != limiter.ReasonQueueFull && limitErr.Reason != limiter.ReasonQueueTimeout {
+ return
+ }
+ adm.observe(ctx, limiter.Rejection{
+ Server: serverName,
+ Tool: toolName,
+ Scope: limitErr.Scope,
+ Reason: limitErr.Reason,
+ Limit: limitErr.Limit,
+ RetryAfter: limitErr.RetryAfter,
+ Waited: waited,
+ Message: limitErr.Error(),
+ })
+}
diff --git a/internal/upstream/managed/admission_test.go b/internal/upstream/managed/admission_test.go
new file mode 100644
index 000000000..d0f561e76
--- /dev/null
+++ b/internal/upstream/managed/admission_test.go
@@ -0,0 +1,268 @@
+package managed
+
+import (
+ "context"
+ "errors"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/upstream/limiter"
+)
+
+func intPtrAdm(v int) *int { return &v }
+
+func durPtrAdm(d time.Duration) *config.Duration {
+ cd := config.Duration(d)
+ return &cd
+}
+
+// newAdmissionClient builds a bare managed client wired to a registry built
+// from cfg, exactly the way Manager.AddServerConfig wires a real one.
+func newAdmissionClient(t *testing.T, cfg *config.Config, serverName string, observe limiter.Observer) (*Client, *limiter.Registry) {
+ t.Helper()
+ t.Setenv("CI", "")
+
+ mc := newTestClientForHealth(t)
+ sc := &config.ServerConfig{Name: serverName, Enabled: true}
+ for _, s := range cfg.Servers {
+ if s.Name == serverName {
+ sc = s
+ }
+ }
+ mc.SetConfig(sc)
+ mc.SetGlobalConfig(cfg)
+
+ reg := limiter.NewRegistry()
+ servers := make(map[string]limiter.Limits)
+ for _, s := range cfg.Servers {
+ r := cfg.ResolveServerConcurrency(s)
+ servers[s.Name] = limiter.Limits{Max: r.MaxConcurrentRequests, QueueSize: r.QueueSize, QueueTimeout: r.QueueTimeout}
+ }
+ g := cfg.ResolveGlobalConcurrency()
+ reg.Apply(limiter.Limits{Max: g.MaxConcurrentRequests, QueueSize: g.QueueSize, QueueTimeout: g.QueueTimeout}, servers)
+
+ mc.SetAdmissionControl(reg, observe)
+ return mc, reg
+}
+
+// TestAcquireAdmission_NoRegistryIsPassthrough is the FR-006 guard.
+func TestAcquireAdmission_NoRegistryIsPassthrough(t *testing.T) {
+ mc := newTestClientForHealth(t)
+ mc.SetGlobalConfig(&config.Config{})
+
+ release, err := mc.acquireAdmission(context.Background(), "tool")
+ require.NoError(t, err)
+ require.NotNil(t, release)
+ release()
+}
+
+// TestAcquireAdmission_QueueFullShedsImmediately covers FR-004 / SC-005: with
+// the cap taken and no pending capacity, admission is refused without waiting.
+func TestAcquireAdmission_QueueFullShedsImmediately(t *testing.T) {
+ sc := &config.ServerConfig{
+ Name: "db",
+ Enabled: true,
+ MaxConcurrentRequests: intPtrAdm(1),
+ QueueSize: intPtrAdm(0),
+ QueueTimeout: durPtrAdm(30 * time.Second),
+ }
+ cfg := &config.Config{Servers: []*config.ServerConfig{sc}}
+
+ var mu sync.Mutex
+ var seen []limiter.Rejection
+ mc, _ := newAdmissionClient(t, cfg, "db", func(_ context.Context, rej limiter.Rejection) {
+ mu.Lock()
+ defer mu.Unlock()
+ seen = append(seen, rej)
+ })
+
+ first, err := mc.acquireAdmission(context.Background(), "query")
+ require.NoError(t, err)
+
+ start := time.Now()
+ _, err = mc.acquireAdmission(context.Background(), "query")
+ elapsed := time.Since(start)
+
+ require.Error(t, err)
+ assert.True(t, errors.Is(err, limiter.ErrQueueFull))
+ assert.Less(t, elapsed, 100*time.Millisecond, "a queue-full shed must not wait (SC-005)")
+
+ var limitErr *limiter.LimitError
+ require.True(t, errors.As(err, &limitErr))
+ assert.Equal(t, limiter.ScopeServer, limitErr.Scope)
+ assert.Equal(t, "db", limitErr.Server)
+ assert.Equal(t, 1, limitErr.Limit)
+ assert.Equal(t, 30*time.Second, limitErr.RetryAfter)
+
+ mu.Lock()
+ defer mu.Unlock()
+ require.Len(t, seen, 1, "the shed must reach the origin-independent observer (FR-012)")
+ assert.Equal(t, "db", seen[0].Server)
+ assert.Equal(t, "query", seen[0].Tool)
+ assert.Equal(t, limiter.ReasonQueueFull, seen[0].Reason)
+ assert.Equal(t, limiter.ScopeServer, seen[0].Scope)
+ assert.Contains(t, seen[0].Message, "db")
+
+ first()
+}
+
+// TestAcquireAdmission_QueueTimeoutUsesResolvedBudget covers FR-004's single
+// absolute deadline: the wait budget comes from the resolved queue_timeout.
+func TestAcquireAdmission_QueueTimeoutUsesResolvedBudget(t *testing.T) {
+ sc := &config.ServerConfig{
+ Name: "db",
+ Enabled: true,
+ MaxConcurrentRequests: intPtrAdm(1),
+ QueueSize: intPtrAdm(2),
+ QueueTimeout: durPtrAdm(120 * time.Millisecond),
+ }
+ cfg := &config.Config{Servers: []*config.ServerConfig{sc}}
+
+ rejections := make(chan limiter.Rejection, 4)
+ mc, _ := newAdmissionClient(t, cfg, "db", func(_ context.Context, rej limiter.Rejection) {
+ rejections <- rej
+ })
+
+ first, err := mc.acquireAdmission(context.Background(), "query")
+ require.NoError(t, err)
+ defer first()
+
+ start := time.Now()
+ _, err = mc.acquireAdmission(context.Background(), "query")
+ elapsed := time.Since(start)
+
+ require.Error(t, err)
+ assert.True(t, errors.Is(err, limiter.ErrQueueTimeout))
+ assert.GreaterOrEqual(t, elapsed, 100*time.Millisecond)
+ assert.Less(t, elapsed, 3*time.Second)
+
+ select {
+ case rej := <-rejections:
+ assert.Equal(t, limiter.ReasonQueueTimeout, rej.Reason)
+ assert.GreaterOrEqual(t, rej.Waited, 100*time.Millisecond)
+ case <-time.After(time.Second):
+ t.Fatal("queue-timeout shed never reached the observer")
+ }
+}
+
+// TestAcquireAdmission_GlobalScopeNeverBlamesAServer covers FR-010's rule that
+// a proxy-wide rejection must not name an upstream.
+func TestAcquireAdmission_GlobalScopeNeverBlamesAServer(t *testing.T) {
+ sc := &config.ServerConfig{Name: "db", Enabled: true}
+ cfg := &config.Config{
+ MaxConcurrentRequests: intPtrAdm(1),
+ QueueSize: intPtrAdm(0),
+ QueueTimeout: durPtrAdm(5 * time.Second),
+ Servers: []*config.ServerConfig{sc},
+ }
+
+ rejections := make(chan limiter.Rejection, 4)
+ mc, _ := newAdmissionClient(t, cfg, "db", func(_ context.Context, rej limiter.Rejection) {
+ rejections <- rej
+ })
+
+ first, err := mc.acquireAdmission(context.Background(), "query")
+ require.NoError(t, err)
+ defer first()
+
+ _, err = mc.acquireAdmission(context.Background(), "query")
+ require.Error(t, err)
+
+ var limitErr *limiter.LimitError
+ require.True(t, errors.As(err, &limitErr))
+ assert.Equal(t, limiter.ScopeGlobal, limitErr.Scope)
+ assert.Empty(t, limitErr.Server, "a global rejection must not carry a server name")
+ assert.NotContains(t, limitErr.Error(), "db")
+
+ rej := <-rejections
+ assert.Equal(t, limiter.ScopeGlobal, rej.Scope)
+ assert.Equal(t, "db", rej.Server, "the metric/activity label still needs the target server")
+ assert.NotContains(t, rej.Message, "db")
+}
+
+// TestAcquireAdmission_CallerCancelIsNotAShed covers the FR-005 edge case: a
+// caller that goes away while queued is reported as cancelled and produces no
+// rejection record.
+func TestAcquireAdmission_CallerCancelIsNotAShed(t *testing.T) {
+ sc := &config.ServerConfig{
+ Name: "db",
+ Enabled: true,
+ MaxConcurrentRequests: intPtrAdm(1),
+ QueueSize: intPtrAdm(2),
+ QueueTimeout: durPtrAdm(30 * time.Second),
+ }
+ cfg := &config.Config{Servers: []*config.ServerConfig{sc}}
+
+ var mu sync.Mutex
+ rejected := 0
+ mc, reg := newAdmissionClient(t, cfg, "db", func(_ context.Context, _ limiter.Rejection) {
+ mu.Lock()
+ rejected++
+ mu.Unlock()
+ })
+
+ first, err := mc.acquireAdmission(context.Background(), "query")
+ require.NoError(t, err)
+ defer first()
+
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan error, 1)
+ go func() {
+ _, aerr := mc.acquireAdmission(ctx, "query")
+ done <- aerr
+ }()
+
+ require.Eventually(t, func() bool { return reg.Server("db").Stats().Queued == 1 },
+ 2*time.Second, 5*time.Millisecond)
+ cancel()
+
+ err = <-done
+ require.Error(t, err)
+ assert.True(t, errors.Is(err, context.Canceled))
+ assert.False(t, errors.Is(err, limiter.ErrQueueTimeout))
+
+ mu.Lock()
+ defer mu.Unlock()
+ assert.Zero(t, rejected, "a caller cancellation is not a shed")
+}
+
+// TestAcquireAdmission_HotReloadRaisesCapMidQueue extends the global-config
+// hot-reload coverage to concurrency limits (FR-021).
+func TestAcquireAdmission_HotReloadRaisesCapMidQueue(t *testing.T) {
+ sc := &config.ServerConfig{
+ Name: "db",
+ Enabled: true,
+ MaxConcurrentRequests: intPtrAdm(1),
+ QueueSize: intPtrAdm(2),
+ QueueTimeout: durPtrAdm(30 * time.Second),
+ }
+ cfg := &config.Config{Servers: []*config.ServerConfig{sc}}
+ mc, reg := newAdmissionClient(t, cfg, "db", nil)
+
+ first, err := mc.acquireAdmission(context.Background(), "query")
+ require.NoError(t, err)
+ defer first()
+
+ done := make(chan error, 1)
+ go func() {
+ _, aerr := mc.acquireAdmission(context.Background(), "query")
+ done <- aerr
+ }()
+ require.Eventually(t, func() bool { return reg.Server("db").Stats().Queued == 1 },
+ 2*time.Second, 5*time.Millisecond)
+
+ // Operator raises the cap: the queued call must be admitted immediately.
+ reg.SetServer("db", limiter.Limits{Max: 3, QueueSize: 2, QueueTimeout: 30 * time.Second})
+
+ select {
+ case aerr := <-done:
+ require.NoError(t, aerr)
+ case <-time.After(2 * time.Second):
+ t.Fatal("raising the cap must admit an eligible queued call immediately (FR-021)")
+ }
+}
diff --git a/internal/upstream/managed/client.go b/internal/upstream/managed/client.go
index 21eb9ea58..68fe26f96 100644
--- a/internal/upstream/managed/client.go
+++ b/internal/upstream/managed/client.go
@@ -99,6 +99,13 @@ type Client struct {
// calculator so the UI shows a proactive Sign-in CTA instead of "Ready". A
// successful call or a fresh Connect clears it. MCP-2084.
oauthCallRequired atomic.Bool
+
+ // admission carries the spec-093 concurrency limiter registry and the
+ // rejection observer installed by the manager. Nil (the default) means no
+ // admission control at all — the zero-config behaviour (FR-006). Swapped
+ // atomically so a hot reload can republish the wiring without a lock; see
+ // admission.go.
+ admission atomic.Pointer[admissionControl]
}
// livenessProber is the minimal core-client surface the health loop needs: a
@@ -642,6 +649,16 @@ func (mc *Client) CallTool(ctx context.Context, toolName string, args map[string
return nil, fmt.Errorf("client not connected (state: %s)", mc.StateManager.GetState().String())
}
+ // Spec 093 FR-003/FR-005: admission control sits here, above
+ // coreClient.CallTool (which is where the call_tool_timeout context is
+ // created), so queue waiting never consumes the execution budget and every
+ // in-process dispatch path is bounded by the same limits.
+ releaseSlot, err := mc.acquireAdmission(ctx, toolName)
+ if err != nil {
+ return nil, err
+ }
+ defer releaseSlot()
+
result, err := mc.coreClient.CallTool(ctx, toolName, args)
if err != nil {
mc.recordCallToolOAuthSignal(toolName, err)
diff --git a/internal/upstream/managed/global_config_hotreload_test.go b/internal/upstream/managed/global_config_hotreload_test.go
index bd87d93c8..776b7f0d2 100644
--- a/internal/upstream/managed/global_config_hotreload_test.go
+++ b/internal/upstream/managed/global_config_hotreload_test.go
@@ -7,6 +7,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/upstream/limiter"
)
// TestSetGlobalConfig_HealthIntervalHotReload is the client-level proof of Codex
@@ -37,3 +38,51 @@ func durPtrHC(d time.Duration) *config.Duration {
cd := config.Duration(d)
return &cd
}
+
+// TestSetGlobalConfig_QueueBudgetHotReload extends the same hot-reload contract
+// to the spec-093 concurrency limits: the wait budget the NEXT admission gets is
+// republished with every generation, and it is always the smallest positive
+// queue_timeout among the enabled scopes (FR-004, FR-021).
+//
+// It asserts the budget the REGISTRY publishes rather than a config-layer
+// re-derivation, because the registry's generation is what admission actually
+// resolves — a deadline computed anywhere else could belong to another
+// generation than the caps it is applied to.
+func TestSetGlobalConfig_QueueBudgetHotReload(t *testing.T) {
+ sc := &config.ServerConfig{
+ Name: "flap-server",
+ Enabled: true,
+ MaxConcurrentRequests: intPtrAdm(2),
+ QueueTimeout: durPtrHC(20 * time.Second),
+ }
+
+ publish := func(cfg *config.Config) *limiter.Registry {
+ reg := limiter.NewRegistry()
+ servers := make(map[string]limiter.Limits)
+ for _, s := range cfg.Servers {
+ r := cfg.ResolveServerConcurrency(s)
+ servers[s.Name] = limiter.Limits{Max: r.MaxConcurrentRequests, QueueSize: r.QueueSize, QueueTimeout: r.QueueTimeout}
+ }
+ g := cfg.ResolveGlobalConcurrency()
+ reg.Apply(limiter.Limits{Max: g.MaxConcurrentRequests, QueueSize: g.QueueSize, QueueTimeout: g.QueueTimeout}, servers)
+ return reg
+ }
+
+ // Boot: only the per-server scope limits, so its timeout is the budget.
+ reg := publish(&config.Config{Servers: []*config.ServerConfig{sc}})
+ assert.Equal(t, 20*time.Second, reg.QueueBudget("flap-server"))
+
+ // Operator adds a stricter global aggregate limiter: the shared absolute
+ // deadline must follow the smaller of the two.
+ reg = publish(&config.Config{
+ MaxConcurrentRequests: intPtrAdm(50),
+ QueueTimeout: durPtrHC(3 * time.Second),
+ Servers: []*config.ServerConfig{sc},
+ })
+ assert.Equal(t, 3*time.Second, reg.QueueBudget("flap-server"))
+
+ // Disabling both limiters leaves nothing to wait for.
+ off := &config.ServerConfig{Name: "flap-server", Enabled: true, MaxConcurrentRequests: intPtrAdm(0)}
+ reg = publish(&config.Config{Servers: []*config.ServerConfig{off}})
+ assert.Equal(t, time.Duration(0), reg.QueueBudget("flap-server"))
+}
diff --git a/internal/upstream/manager.go b/internal/upstream/manager.go
index 0652b7cf5..cdce03ab4 100644
--- a/internal/upstream/manager.go
+++ b/internal/upstream/manager.go
@@ -2,6 +2,7 @@ package upstream
import (
"context"
+ "errors"
"fmt"
"maps"
"os/exec"
@@ -20,6 +21,7 @@ import (
"github.com/smart-mcp-proxy/mcpproxy-go/internal/storage"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/transport"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/upstream/core"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/upstream/limiter"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/upstream/managed"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/upstream/types"
)
@@ -126,6 +128,16 @@ type Manager struct {
// Tool discovery callback for notifications/tools/list_changed handling
toolDiscoveryCallback func(ctx context.Context, serverName string) error
+
+ // limiters owns the spec-093 concurrency limiter instances (one per upstream
+ // plus the proxy-wide aggregate). Created once and never replaced — hot
+ // reload republishes limits INTO it so occupancy is shared across
+ // generations (FR-021).
+ limiters *limiter.Registry
+ // rejectObserver is the origin-independent shed seam (FR-012/FR-013),
+ // installed by the runtime. Stored as an atomic pointer because it is set
+ // after construction while clients may already exist.
+ rejectObserver atomic.Pointer[limiter.Observer]
}
func cloneServerConfig(cfg *config.ServerConfig) *config.ServerConfig {
@@ -180,8 +192,12 @@ func NewManager(logger *zap.Logger, globalConfig *config.Config, boltStorage *st
shutdownCtx: shutdownCtx,
shutdownCancel: shutdownCancel,
storageMgr: storageMgr,
+ limiters: limiter.NewRegistry(),
}
manager.globalConfig.Store(globalConfig)
+ // Spec 093: publish the initial limit generation. With no limits configured
+ // this allocates nothing and every admission is a passthrough (FR-006).
+ manager.applyConcurrencyLimits(globalConfig)
// Set up OAuth completion callback to trigger connection retries (in-process)
tokenManager := oauth.GetTokenStoreManager()
@@ -298,6 +314,12 @@ func (m *Manager) SetLogConfig(logConfig *config.LogConfig) {
func (m *Manager) SetGlobalConfig(globalConfig *config.Config) {
m.globalConfig.Store(globalConfig)
+ // Spec 093 FR-021: republish one atomic generation of concurrency limits
+ // (global + per-server) into the SAME limiter instances, so running calls
+ // keep counting against the new caps and queued calls keep their original
+ // deadlines.
+ m.applyConcurrencyLimits(globalConfig)
+
m.mu.RLock()
clients := make([]*managed.Client, 0, len(m.clients))
for _, client := range m.clients {
@@ -374,6 +396,9 @@ func (m *Manager) AddServerConfig(id string, serverConfig *config.ServerConfig)
// Use thread-safe setter to avoid race with GetServerState()
m.mu.Unlock()
existingClient.SetConfig(serverConfig)
+ // Spec 093: the per-server limits may have changed even though the
+ // transport config did not.
+ m.applyServerConcurrency(serverConfig)
return nil
}
}
@@ -409,6 +434,10 @@ func (m *Manager) AddServerConfig(id string, serverConfig *config.ServerConfig)
client.SetToolDiscoveryCallback(m.toolDiscoveryCallback)
}
+ // Spec 093: install admission control before the client becomes reachable,
+ // so no dispatch can ever see a client without its limiter wiring.
+ client.SetAdmissionControl(m.limiters, m.currentRejectObserver())
+
m.clients[id] = client
m.logger.Info("Added upstream server configuration",
zap.String("id", id),
@@ -417,6 +446,11 @@ func (m *Manager) AddServerConfig(id string, serverConfig *config.ServerConfig)
// IMPORTANT: Release lock before disconnecting to prevent deadlock
m.mu.Unlock()
+ // Spec 093: publish this server's limits (or retire them when the server is
+ // disabled/quarantined, FR-009). Done off the lock — Retire wakes queued
+ // callers.
+ m.applyServerConcurrency(serverConfig)
+
// Disconnect old client outside lock to avoid blocking other operations
if clientToDisconnect != nil {
_ = clientToDisconnect.Disconnect()
@@ -515,6 +549,17 @@ func (m *Manager) RemoveServer(id string) {
}
m.mu.Unlock()
+ // Spec 093 FR-009: tombstone the limiter first so queued calls fail
+ // immediately with the server-unavailable semantics instead of waiting out
+ // their queue deadline against a server that no longer exists.
+ name := id
+ if exists && client != nil {
+ if cfg := client.GetConfig(); cfg != nil && cfg.Name != "" {
+ name = cfg.Name
+ }
+ }
+ m.retireServerConcurrency(name)
+
// Disconnect outside the lock to avoid blocking other operations
if exists {
m.logger.Info("Removing upstream server",
@@ -1138,12 +1183,13 @@ func (m *Manager) CallTool(ctx context.Context, toolName string, args map[string
zap.String("server_name", serverName),
zap.String("actual_tool_name", actualToolName))
+ // Spec 093 FR-008: resolve the target client under the manager lock and
+ // RELEASE it before anything that can block — the reconnect-on-use attempt,
+ // limiter admission inside the managed client, and the upstream call
+ // itself. Holding m.mu.RLock across a queued call would make a saturated
+ // upstream stall every server add/remove/disable and every config reload.
m.mu.RLock()
- defer m.mu.RUnlock()
-
- m.logger.Debug("CallTool: acquired read lock, searching for client",
- zap.String("server_name", serverName),
- zap.Int("total_clients", len(m.clients)))
+ clientCount := len(m.clients)
// Find the client for this server
var targetClient *managed.Client
@@ -1153,6 +1199,11 @@ func (m *Manager) CallTool(ctx context.Context, toolName string, args map[string
break
}
}
+ m.mu.RUnlock()
+
+ m.logger.Debug("CallTool: resolved client under read lock",
+ zap.String("server_name", serverName),
+ zap.Int("total_clients", clientCount))
if targetClient == nil {
m.logger.Error("CallTool: no client found",
@@ -1187,18 +1238,13 @@ func (m *Manager) CallTool(ctx context.Context, toolName string, args map[string
zap.String("tool", actualToolName),
zap.String("state", state.String()))
- // Release the read lock during reconnection — Connect acquires mc.mu
- // and we must not hold m.mu.RLock while blocking on a potentially
- // slow network operation.
- m.mu.RUnlock()
-
+ // No manager lock is held here (FR-008): the client was snapshotted
+ // above and released, so a slow reconnect cannot block server
+ // management.
reconnectCtx, reconnectCancel := context.WithTimeout(ctx, 15*time.Second)
reconnectErr := targetClient.TryReconnectSync(reconnectCtx)
reconnectCancel()
- // Re-acquire the read lock
- m.mu.RLock()
-
if reconnectErr != nil {
m.logger.Warn("reconnect_on_use: reconnect failed, falling through to error",
zap.String("server", serverName),
@@ -1246,6 +1292,16 @@ func (m *Manager) CallTool(ctx context.Context, toolName string, args map[string
zap.Error(err),
zap.Bool("has_result", result != nil))
if err != nil {
+ // Spec 093 FR-011: a limiter rejection is a typed identity that must
+ // survive end-to-end (MCP isError text, REST 429 + Retry-After). Return
+ // it verbatim — its message is already caller-ready and the string
+ // enrichment below would both mangle it and mis-classify it (a
+ // queue-full shed is not an upstream rate limit).
+ var limitErr *limiter.LimitError
+ if errors.As(err, &limitErr) {
+ return nil, err
+ }
+
// Enrich errors at source with server context
errStr := err.Error()
diff --git a/oas/docs.go b/oas/docs.go
index fdd3e7b62..50bfcd8ce 100644
--- a/oas/docs.go
+++ b/oas/docs.go
@@ -6,10 +6,10 @@ import "github.com/swaggo/swag/v2"
const docTemplate = `{
"schemes": {{ marshal .Schemes }},
- "components": {"schemas":{"config.Config":{"properties":{"activity_cleanup_interval_min":{"description":"Background cleanup interval in minutes (default: 60)","type":"integer"},"activity_max_records":{"description":"Max records before pruning (default: 100000)","type":"integer"},"activity_max_response_size":{"description":"Response truncation limit in bytes (default: 65536)","type":"integer"},"activity_max_size_mb":{"description":"Max total activity-log size in MB before pruning oldest (default: 256, 0=disabled)","type":"integer"},"activity_retention_days":{"description":"Activity logging settings (RFC-003)","type":"integer"},"allow_private_registry_fetch":{"description":"AllowPrivateRegistryFetch opts out of the registry SSRF guard (MCP-1076,\nCWE-918). By default (false) registry fetches refuse any host that is — or\nresolves to — a non-routable address (loopback, RFC1918/CGNAT private,\nlink-local incl. the 169.254.169.254 cloud-metadata endpoint), so a\nmalicious or typo'd registry source cannot turn the daemon into a\nrequest-forgery vector against internal services.\n\nThis opt-out is BLANKET (all-or-nothing): setting it true disables the\nguard for EVERY non-routable range at once — loopback, RFC1918/CGNAT\nprivate, link-local AND the 169.254.169.254 cloud-metadata endpoint. There\nis no way to allow only loopback; enabling it for a localhost dev registry\nalso re-opens the cloud-metadata SSRF vector. Set true ONLY when you\nintentionally run a trusted registry mirror on an internal/private address,\nideally on a host with no cloud-metadata exposure. The change takes effect\nonly on daemon (re)start or config reload.","type":"boolean"},"allow_server_add":{"type":"boolean"},"allow_server_remove":{"type":"boolean"},"api_key":{"description":"Security settings","type":"string"},"call_tool_timeout":{"type":"string"},"check_server_repo":{"description":"Repository detection settings","type":"boolean"},"code_execution_max_tool_calls":{"description":"Max tool calls per execution (0 = unlimited, default: 0)","type":"integer"},"code_execution_pool_size":{"description":"JavaScript runtime pool size (default: 10)","type":"integer"},"code_execution_timeout_ms":{"description":"Timeout in milliseconds (default: 120000, max: 600000)","type":"integer"},"data_dir":{"type":"string"},"debug_search":{"type":"boolean"},"disable_management":{"type":"boolean"},"docker_isolation":{"$ref":"#/components/schemas/config.DockerIsolationConfig"},"docker_recovery":{"$ref":"#/components/schemas/config.DockerRecoveryConfig"},"enable_code_execution":{"description":"Code execution settings","type":"boolean"},"enable_prompts":{"description":"Prompts settings","type":"boolean"},"enable_socket":{"description":"Enable Unix socket/named pipe for local IPC (default: true)","type":"boolean"},"enable_tray":{"description":"Deprecated: EnableTray is unused and has no runtime effect. Kept for backward compatibility.","type":"boolean"},"environment":{"$ref":"#/components/schemas/secureenv.EnvConfig"},"features":{"$ref":"#/components/schemas/config.FeatureFlags"},"forward_proxy_env":{"description":"ForwardProxyEnv opts in to forwarding the ambient HTTP(S)/ALL/NO/FTP proxy\nenvironment variables to spawned stdio upstream servers (MCP-2769). OFF by\ndefault: proxy URLs commonly embed credentials (http://user:pass@proxy), so\nforwarding them to every upstream is a credential-leak risk. When enabled,\nvalues are forwarded with their userinfo (credentials) redacted.","type":"boolean"},"health_check_interval":{"description":"Discovery \u0026 health-check cadence (spec 074, #608). Both are *Duration\ntri-state pointers: nil = inherit the built-in default; a pointer to 0s =\nthe loop is disabled; a positive value = that interval. Defaults live only\nin the resolvers (ResolveHealthCheckInterval / ResolveToolDiscoveryInterval)\nso an unset key behaves exactly as before this feature (SC-005). Validated\nin Validate(): health-check ∈ {0} ∪ [5s,1h]; tool-discovery ∈ {0} ∪ [30s,24h].","type":"string"},"init_timeout":{"description":"InitTimeout is the global default deadline for an upstream's MCP\n` + "`" + `initialize` + "`" + ` handshake (MCP-3322 / GH #760). *Duration tri-state: nil =\ninherit the built-in 30s default; a positive value = that deadline. A\nper-server InitTimeout overrides this. Resolved by ResolveInitTimeout;\nvalidated to {0} ∪ [1s, 30m] in Validate(). Servers doing legitimate\nfirst-run warmup (cache/index build) before answering ` + "`" + `initialize` + "`" + ` can\nraise this so they are not killed mid-startup.","type":"string"},"instructions":{"description":"Instructions text returned in the MCP initialize response to guide AI agents.\nWhen empty, a built-in default is used that explains retrieve_tools workflow.","type":"string"},"intent_declaration":{"$ref":"#/components/schemas/config.IntentDeclarationConfig"},"listen":{"type":"string"},"logging":{"$ref":"#/components/schemas/config.LogConfig"},"max_result_size_chars":{"description":"Advertised on every tool as ` + "`" + `_meta.anthropic/maxResultSizeChars` + "`" + `; raises Claude Code's inline-response ceiling from 50k to up to 500k chars. Set to 0 to disable.","type":"integer"},"mcpServers":{"items":{"$ref":"#/components/schemas/config.ServerConfig"},"type":"array","uniqueItems":false},"oauth_expiry_warning_hours":{"description":"Health status settings","type":"number"},"observability":{"$ref":"#/components/schemas/config.ObservabilityConfig"},"output_sanitisation":{"$ref":"#/components/schemas/config.OutputSanitisationConfig"},"output_validation":{"$ref":"#/components/schemas/config.OutputValidationConfig"},"profiles":{"description":"Profiles are optional named, server-scoped views exposed at /mcp/p/\u003cname\u003e\n(Spec 057). Absent/empty is fully supported — /mcp is unchanged and configs\nwithout this key serialize byte-identically (SC-004).","items":{"$ref":"#/components/schemas/config.ProfileConfig"},"type":"array","uniqueItems":false},"quarantine_enabled":{"description":"QuarantineEnabled controls whether quarantine is active. It gates two\nthings together:\n 1. Server-level auto-quarantine for newly added servers (issue #370).\n When true, servers added via the upstream_servers MCP tool or the\n REST API default to quarantined=true; when false, they default to\n quarantined=false. Explicit per-request values always win.\n 2. Tool-level quarantine (Spec 032): per-tool SHA-256 approval of\n tool descriptions/schemas.\nWhen nil (default), quarantine is enabled (secure by default). Set to\nexplicit false to opt out of both. Per-server SkipQuarantine still\napplies for the tool-level check on individual servers.","type":"boolean"},"read_only_mode":{"type":"boolean"},"registries":{"description":"Registries configuration for MCP server discovery","items":{"$ref":"#/components/schemas/config.RegistryEntry"},"type":"array","uniqueItems":false},"registries_locked":{"description":"RegistriesLocked is an enterprise stub knob (MCP-866): when true, runtime\nadditions of custom registries (e.g. ` + "`" + `registry add-source` + "`" + `, the REST/MCP\nadd-source surface) are rejected so an administrator can pin the discovery\nsources. Built-in defaults are unaffected. Documented but otherwise inert\nbeyond the add-source rejection.","type":"boolean"},"require_mcp_auth":{"description":"Require authentication on /mcp endpoint (default: false)","type":"boolean"},"reveal_secret_headers":{"description":"RevealSecretHeaders, when true, disables the redaction of the\nsecret-bearing server fields — sensitive header values (Authorization,\nX-API-Key, Cookie, …), env-var secrets, and URL query credentials — in\nresponses from the ` + "`" + `upstream_servers` + "`" + ` MCP tool, the ` + "`" + `/api/v1/servers` + "`" + `\nREST API, and the SSE event stream. It also lets URL secrets echoed\ninto last_error / health.detail through unscrubbed.\n\nDefault false — sensitive values are surfaced masked as\n` + "`" + `••••\u003clast2\u003e (\u003cN\u003e chars)` + "`" + ` (error strings use ` + "`" + `***REDACTED***` + "`" + `) so an\nMCP agent cannot read Bearer tokens / API keys / URL secrets out of\nanother upstream's config (PR #425, issue #872). ${env:…}/${keyring:…}\nreferences are labels, not secrets, and pass through unchanged.\n\nThe Web UI / macOS tray edit forms work without seeing the real\nvalues: PATCH /api/v1/servers/{id} deep-merges (omitted keys are\npreserved, see ` + "`" + `headers_remove` + "`" + ` / ` + "`" + `env_remove` + "`" + ` for explicit\ndeletes), so clients compute a diff and only send the keys that\nactually changed. Redacted-but-unchanged values never round-trip\n— the backend keeps the real string. Set this to true if a\ndownstream tool genuinely needs raw values in the response.","type":"boolean"},"routing_mode":{"description":"Routing mode (Spec 031): how MCP tools are exposed to clients\nValid values: \"retrieve_tools\" (default), \"direct\", \"code_execution\"","type":"string"},"security":{"$ref":"#/components/schemas/config.SecurityConfig"},"sensitive_data_detection":{"$ref":"#/components/schemas/config.SensitiveDataDetectionConfig"},"telemetry":{"$ref":"#/components/schemas/config.TelemetryConfig"},"tls":{"$ref":"#/components/schemas/config.TLSConfig"},"tokenizer":{"$ref":"#/components/schemas/config.TokenizerConfig"},"tool_discovery_interval":{"type":"string"},"tool_response_limit":{"type":"integer"},"tool_response_mode":{"description":"Tool response mode (Spec 085): how retrieve_tools serializes results.\nValid values: \"\" (= full), \"full\" (default: today's schema-bearing\nentries), \"compact\" (signature + first-sentence entries). Orthogonal to\nrouting_mode — routing_mode selects the tool SURFACE, this selects the\nSERIALIZATION within the retrieve_tools surface. Serialization-only: it\nnever affects the query, ranking, or result set. Hot-reloadable.","type":"string"},"tool_response_session_risk_warning":{"description":"ToolResponseSessionRiskWarning controls whether the prose ` + "`" + `warning` + "`" + ` field\nis included in the ` + "`" + `session_risk` + "`" + ` object returned by ` + "`" + `retrieve_tools` + "`" + `.\nThe structured fields (level, lethal_trifecta, has_open_world_tools, etc.)\nare always included. Default: false (quiet for LLM clients) — see issue #406.\nMost tools lack annotations, so the MCP-spec defaults treat them as fully\npermissive across all three risk axes, which makes the prose warning fire\non almost every call and wastes tokens.","type":"boolean"},"tools_limit":{"type":"integer"},"toon_min_savings_pct":{"description":"ToonMinSavingsPct is the minimum byte-savings percentage (validated\n1-90; 0/unset → 15) the complete TOON emission (marker + hint + body)\nmust achieve over the exact passthrough emission for adaptive mode to\nencode a block. Byte savings approximate token savings for the tabular\npayload class; the spec-083 profiler reports true token deltas.\nGlobal-only (no per-server override, FR-001).","type":"integer"},"toon_output":{"description":"ToonOutput selects the TOON encoding mode for call_tool_* result text\nblocks (spec 084): \"off\" (default — responses byte-identical to\npre-feature behavior), \"adaptive\" (encode only tabular-uniform payloads\nthat beat compact JSON by ToonMinSavingsPct), or \"always\"\n(benchmark/debug only — encodes every JSON-parseable block and can\nINCREASE token cost). Per-server override: ServerConfig.ToonOutput.\nResolved by ResolveToonOutput; hot-reloadable.","type":"string"},"top_k":{"description":"Deprecated: TopK is superseded by ToolsLimit and has no runtime effect. Kept for backward compatibility.","type":"integer"},"tray_endpoint":{"description":"Tray endpoint override (unix:// or npipe://)","type":"string"},"trusted_hosts":{"description":"TrustedHosts lists non-loopback Host header values accepted on loopback\nlisteners (GH #898). DNS-rebinding protection rejects requests whose Host\nheader is not a loopback address when mcpproxy listens on loopback; a\nreverse proxy (nginx → 127.0.0.1) forwarding the public domain in Host\ntrips it. Entries are hostnames, case-insensitive; an entry without a\nport matches any port, with a port it must match exactly; a leading dot\n(\".example.com\") is a subdomain wildcard. The single entry \"*\" disables\nHost and Origin validation entirely. The same list also validates the\nOrigin header when present (MCP spec DNS-rebinding defense). Empty\n(default) keeps full protection. Env override: MCPPROXY_TRUSTED_HOSTS\n(comma-separated).","items":{"type":"string"},"type":"array","uniqueItems":false},"update_check":{"$ref":"#/components/schemas/config.UpdateCheckConfig"}},"type":"object"},"config.CustomPattern":{"properties":{"category":{"description":"Category (defaults to \"custom\")","type":"string"},"keywords":{"description":"Keywords to match (mutually exclusive with Regex)","items":{"type":"string"},"type":"array","uniqueItems":false},"name":{"description":"Unique identifier for this pattern","type":"string"},"regex":{"description":"Regex pattern (mutually exclusive with Keywords)","type":"string"},"severity":{"description":"Risk level: critical, high, medium, low","type":"string"}},"type":"object"},"config.DeepScanConfig":{"description":"DeepScan is the opt-in \"deep scan\" layer (Spec 077 US3). It subsumes the\ndeprecated top-level scanner_fetch_package_source / scanner_disable_no_new_privileges\nkeys (migrated on load) and gates the heavy Docker-based scanners + source\nextraction. Disabled by default (FR-006): only the deterministic in-process\nbaseline scanner runs. A deep-scan failure NEVER changes the baseline verdict\n(FR-007/FR-008).","properties":{"disable_no_new_privileges":{"description":"DisableNoNewPrivileges, when true, omits the ` + "`" + `--security-opt\nno-new-privileges` + "`" + ` flag from scanner container runs (snap-docker/AppArmor\nescape hatch). Absorbs the deprecated top-level\nscanner_disable_no_new_privileges. Default false.","type":"boolean"},"enabled":{"description":"Enabled is the master opt-in for the heavy layer (FR-006). Default false.","type":"boolean"},"fetch_package_source":{"description":"FetchPackageSource controls whether the scanner fetches the PUBLISHED\nsource of package-runner servers (npx/uvx) — without executing it — when\nno local source is available. Absorbs the deprecated top-level\nscanner_fetch_package_source. Default (nil) is ENABLED within deep scan.","type":"boolean"},"scanners":{"description":"Scanners optionally restricts which deep scanners may run under the\numbrella (by scanner id). Empty ⇒ all enabled deep scanners are eligible.","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.DockerIsolationConfig":{"description":"Docker isolation settings","properties":{"cpu_limit":{"description":"CPU limit for containers","type":"string"},"default_images":{"additionalProperties":{"type":"string"},"description":"Map of runtime type to Docker image","type":"object"},"enable_cache_volume":{"description":"Mount shared cache volumes for faster restarts (default: true)","type":"boolean"},"enabled":{"description":"Global enable/disable for Docker isolation (legacy; superseded by Mode)","type":"boolean"},"extra_args":{"description":"Additional docker run arguments","items":{"type":"string"},"type":"array","uniqueItems":false},"log_driver":{"description":"Docker log driver (default: json-file)","type":"string"},"log_max_files":{"description":"Maximum number of log files (default: 3)","type":"string"},"log_max_size":{"description":"Maximum size of log files (default: 100m)","type":"string"},"memory_limit":{"description":"Memory limit for containers","type":"string"},"mode":{"description":"Isolation mode: \"docker\" | \"sandbox\" | \"none\" (MCP-34.2). Unset per-server inherits the global mode; unset globally falls back to the legacy \"enabled\" flag (true ⇒ docker, false ⇒ none)","type":"string","x-enum-varnames":["IsolationModeDocker","IsolationModeSandbox","IsolationModeNone"]},"network_mode":{"description":"Docker network mode (default: bridge)","type":"string"},"registry":{"description":"Custom registry (defaults to docker.io)","type":"string"},"timeout":{"description":"Container startup timeout","type":"string"}},"type":"object"},"config.DockerRecoveryConfig":{"description":"Docker recovery settings","properties":{"enabled":{"description":"Enable Docker recovery monitoring (default: true)","type":"boolean"},"max_retries":{"description":"Maximum retry attempts (0 = unlimited)","type":"integer"},"notify_on_failure":{"description":"Show notification on recovery failure (default: true)","type":"boolean"},"notify_on_retry":{"description":"Show notification on each retry (default: false)","type":"boolean"},"notify_on_start":{"description":"Show notification when recovery starts (default: true)","type":"boolean"},"notify_on_success":{"description":"Show notification on successful recovery (default: true)","type":"boolean"},"persistent_state":{"description":"Save recovery state across restarts (default: true)","type":"boolean"}},"type":"object"},"config.FeatureFlags":{"description":"Deprecated: Features flags are unused and have no runtime effect. Kept for backward compatibility.","properties":{"enable_async_storage":{"type":"boolean"},"enable_caching":{"type":"boolean"},"enable_contract_tests":{"type":"boolean"},"enable_debug_logging":{"description":"Development features","type":"boolean"},"enable_docker_isolation":{"type":"boolean"},"enable_event_bus":{"type":"boolean"},"enable_health_checks":{"type":"boolean"},"enable_metrics":{"type":"boolean"},"enable_oauth":{"description":"Security features","type":"boolean"},"enable_observability":{"description":"Observability features","type":"boolean"},"enable_quarantine":{"type":"boolean"},"enable_runtime":{"description":"Runtime features","type":"boolean"},"enable_search":{"description":"Storage features","type":"boolean"},"enable_sse":{"type":"boolean"},"enable_tracing":{"type":"boolean"},"enable_tray":{"type":"boolean"},"enable_web_ui":{"description":"UI features","type":"boolean"}},"type":"object"},"config.IntentDeclarationConfig":{"description":"Intent declaration settings (Spec 018)","properties":{"strict_server_validation":{"description":"StrictServerValidation controls whether server annotation mismatches\ncause rejection (true) or just warnings (false).\nDefault: true (reject mismatches)","type":"boolean"}},"type":"object"},"config.IsolationConfig":{"description":"Per-server isolation settings","properties":{"enabled":{"description":"Enable Docker isolation for this server (nil = inherit global; legacy, superseded by Mode)","type":"boolean"},"extra_args":{"description":"Additional docker run arguments for this server","items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"description":"Custom Docker image (overrides default)","type":"string"},"log_driver":{"description":"Docker log driver override for this server","type":"string"},"log_max_files":{"description":"Maximum number of log files override","type":"string"},"log_max_size":{"description":"Maximum size of log files override","type":"string"},"mode":{"$ref":"#/components/schemas/config.IsolationMode"},"network_mode":{"description":"Custom network mode for this server","type":"string"},"working_dir":{"description":"Custom working directory in container","type":"string"}},"type":"object"},"config.IsolationMode":{"description":"Isolation mode: \"docker\" | \"sandbox\" | \"none\" (MCP-34.2). Unset per-server inherits the global mode; unset globally falls back to the legacy \"enabled\" flag (true ⇒ docker, false ⇒ none)","type":"string","x-enum-varnames":["IsolationModeDocker","IsolationModeSandbox","IsolationModeNone"]},"config.LogConfig":{"description":"Logging configuration","properties":{"compress":{"type":"boolean"},"enable_console":{"type":"boolean"},"enable_file":{"type":"boolean"},"filename":{"type":"string"},"json_format":{"type":"boolean"},"level":{"type":"string"},"log_dir":{"description":"Custom log directory","type":"string"},"max_age":{"description":"days","type":"integer"},"max_backups":{"description":"number of backup files","type":"integer"},"max_size":{"description":"MB","type":"integer"}},"type":"object"},"config.MetricsExporterConfig":{"description":"Metrics gates the Prometheus /metrics scrape endpoint (MCP-32). Disabled\nby default — operators opt in for k8s/enterprise deployments.","properties":{"enabled":{"description":"Enabled exposes /metrics on the existing HTTP listener when true.","type":"boolean"}},"type":"object"},"config.OAuthConfig":{"description":"OAuth configuration (keep even when empty to signal OAuth requirement)","properties":{"client_id":{"type":"string"},"client_secret":{"type":"string"},"extra_params":{"additionalProperties":{"type":"string"},"description":"Additional OAuth parameters (e.g., RFC 8707 resource)","type":"object"},"pkce_enabled":{"type":"boolean"},"redirect_uri":{"type":"string"},"scopes":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.ObservabilityConfig":{"description":"Observability settings (Spec 069): usage aggregate cache/persistence cadence.","properties":{"metrics":{"$ref":"#/components/schemas/config.MetricsExporterConfig"},"tracing":{"$ref":"#/components/schemas/config.TracingExporterConfig"},"usage_cache_ttl":{"description":"UsageCacheTTL bounds the freshness of the usage endpoint's read cache for\nwide windows (FR-005). Default 5s.","type":"string"},"usage_persist_interval":{"description":"UsagePersistInterval is how often the actor-owned usage aggregate snapshot\nis flushed to storage. Default 30s.","type":"string"}},"type":"object"},"config.OutputSanitisationConfig":{"description":"Output sanitisation settings (Spec 054 Track B)","properties":{"max_redactions":{"description":"cap on redactions per response; default 100","type":"integer"},"response_action":{"description":"\"spotlight\" | \"redact\" | \"block\"; default \"spotlight\"","type":"string"},"spotlight_untrusted":{"description":"wrap untrusted output in spotlight markers; default true","type":"boolean"},"strip_classes":{"description":"classes to strip: ansi/c0c1/bidi/zero_width","items":{"type":"string"},"type":"array","uniqueItems":false},"strip_control_chars":{"description":"strip control-character classes; default false","type":"boolean"}},"type":"object"},"config.OutputValidationConfig":{"description":"Output-schema validation settings (Spec 056)","properties":{"max_bytes":{"description":"structured payload byte cap; default 5\u003c\u003c20","type":"integer"},"max_depth":{"description":"nesting depth cap; default 64","type":"integer"},"missing_structured_content":{"description":"\"allow\" | \"block\"; default \"allow\"","type":"string"},"mode":{"description":"\"off\" | \"warn\" | \"strict\"; default \"warn\"","type":"string"}},"type":"object"},"config.ProfileConfig":{"properties":{"name":{"description":"URL slug, validated","type":"string"},"servers":{"description":"references to mcpServers[].name","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.RegistryEntry":{"properties":{"count":{"description":"number or string","type":"string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"protocol":{"type":"string"},"provenance":{"description":"Provenance is the trust tag for this registry (MCP-866):\nRegistryProvenanceOfficial for built-in defaults, RegistryProvenanceCustom\nfor user-added registries. It is authoritatively (re)computed by the\nregistries merge from whether the ID is a shipped default — a user cannot\nclaim \"official\" by writing it into their config.","type":"string"},"requires_key":{"description":"RequiresKey marks a registry that needs an API key to be queried. When\ntrue and no key is configured, the registry is skipped/marked unavailable\nrather than failing the whole search (FR-008).","type":"boolean"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"url":{"type":"string"}},"type":"object"},"config.SecurityConfig":{"description":"Security scanner settings (Spec 039)","properties":{"deep_scan":{"$ref":"#/components/schemas/config.DeepScanConfig"},"integrity_check_interval":{"type":"string"},"integrity_check_on_restart":{"type":"boolean"},"runtime_read_only":{"type":"boolean"},"runtime_tmpfs_size":{"type":"string"},"scan_timeout_default":{"type":"string"},"scanner_disable_no_new_privileges":{"description":"Deprecated (Spec 077 US3): migrated on load into DeepScan.DisableNoNewPrivileges\n(see migrateDeepScanConfig). Retained only so existing configs that still carry\nthe top-level key parse; consumers MUST read the effective value via\nSecurityConfig.IsDisableNoNewPrivileges. Cleared after migration.\n\nScannerDisableNoNewPrivileges, when true, omits the\n` + "`" + `--security-opt no-new-privileges` + "`" + ` flag from scanner container runs.\n\nBackground: snap-installed Docker on Ubuntu confines dockerd under the\n` + "`" + `snap.docker.dockerd` + "`" + ` AppArmor profile. When runc tries to transition\nthe container into the inner ` + "`" + `docker-default` + "`" + ` profile to exec the\nentrypoint, AppArmor refuses the transition because NO_NEW_PRIVS\nforbids privilege/profile changes on exec — the result is EPERM\n(\"operation not permitted\") and every scanner fails immediately.\n\nSet this to true ONLY on hosts hitting that incompatibility. Scanner\ncontainers still run with read-only rootfs, tmpfs /tmp, no-network by\ndefault, and read-only source mounts, so the marginal isolation loss\nis small. The preferred fix remains replacing snap docker with a\ndistro-packaged docker.","type":"boolean"},"scanner_fetch_package_source":{"description":"Deprecated (Spec 077 US3): migrated on load into DeepScan.FetchPackageSource\n(see migrateDeepScanConfig). Retained only so existing configs that still carry\nthe top-level key parse; consumers MUST read the effective value via\nSecurityConfig.EffectiveFetchPackageSource. Cleared after migration.\n\nScannerFetchPackageSource controls whether the scanner fetches the\nPUBLISHED source of package-runner servers (npx/uvx) — without executing\nit — when no local source is available (no Docker container, no local\npackage cache, no working_dir). This is the primary quarantine/scan\ntarget: a quarantined-on-add server is never run locally, so without this\nthe scan degrades to tool-definitions-only (no real source-level\nanalysis). See MCP-2206.\n\nFetching uses ` + "`" + `npm pack --ignore-scripts` + "`" + ` (npm) and ` + "`" + `uv pip download` + "`" + ` /\n` + "`" + `pip download` + "`" + ` with ` + "`" + `--only-binary=:all:` + "`" + ` (Python), which only download +\nunpack archives and NEVER run install, build, or setup.py — a scanner must\nnot execute the untrusted code it is scanning. The Python\n` + "`" + `--only-binary=:all:` + "`" + ` flag is required because downloading an sdist would\ninvoke its build backend (setup.py); packages with no wheel fall back to\ntool-definitions-only instead. Extraction is hardened against path\ntraversal and decompression bombs.\n\nDefault (nil) is ENABLED. Set to false on air-gapped deployments to\nforbid the scanner's network egress; such servers then fall back to the\ntool-definitions-only scan with no regression.","type":"boolean"},"scanner_registry_url":{"type":"string"},"tpa_bundle_path":{"description":"TPABundlePath is the filesystem path to the tpa-db scanner-bundle.json\nthe offline TPA scanner runs (spec 086 FR-019: the signature-DB location\nMUST be configuration-driven, not hardcoded). Empty (the default) runs the\ncorpus embedded in this build.\n\nEnv override: MCPPROXY_TPA_BUNDLE_PATH. Hot-reloadable — the path is\nre-read on every config.reloaded event via\nscanner.Service.ApplySecurityConfig, so a corpus refresh needs no restart.\nA configured bundle that fails to read/parse/version-check/compile is\nREFUSED and the previously active corpus stays live (fail-closed, never\nfail-empty); the reason is logged and surfaced in the security overview's\nsignature_bundle.load_error.","type":"string"}},"type":"object"},"config.SensitiveDataDetectionConfig":{"description":"Sensitive data detection settings (Spec 026)","properties":{"categories":{"additionalProperties":{"type":"boolean"},"description":"Enable/disable specific detection categories","type":"object"},"custom_patterns":{"description":"User-defined detection patterns","items":{"$ref":"#/components/schemas/config.CustomPattern"},"type":"array","uniqueItems":false},"enabled":{"description":"Enable sensitive data detection (default: true)","type":"boolean"},"entropy_threshold":{"description":"Shannon entropy threshold for high-entropy detection (default: 4.5)","type":"number"},"max_payload_size_kb":{"description":"Max size to scan before truncating (default: 1024)","type":"integer"},"scan_requests":{"description":"Scan tool call arguments (default: true)","type":"boolean"},"scan_responses":{"description":"Scan tool responses (default: true)","type":"boolean"},"sensitive_keywords":{"description":"Keywords to flag","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.ServerConfig":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges is the per-server intent to auto-approve tool\nchanges/additions (disabling per-server rug-pull protection). Supersedes\nskip_quarantine. MCP-2930 only ACCEPTS, persists, and migrates this flag — it\nis NOT yet consulted at runtime; auto-approval is still governed by\nSkipQuarantine until the trust-baseline behavior change (MCP-2931) migrates the\nruntime consumers onto it.\nTri-state pointer (mirrors QuarantineEnabled): nil = unset (inherit/migrate\nfrom legacy skip_quarantine), explicit true/false = honored as-is so an\nexplicit auto_approve_tool_changes:false overrides a legacy skip_quarantine:true.\nRead via IsAutoApproveToolChanges().","type":"boolean"},"command":{"type":"string"},"created":{"type":"string"},"disabled_tools":{"description":"Denylist: these tools are hidden; mutually exclusive with enabled_tools","items":{"type":"string"},"type":"array","uniqueItems":false},"enabled":{"type":"boolean"},"enabled_tools":{"description":"Allowlist: only these tools are exposed; mutually exclusive with disabled_tools","items":{"type":"string"},"type":"array","uniqueItems":false},"env":{"additionalProperties":{"type":"string"},"type":"object"},"headers":{"additionalProperties":{"type":"string"},"description":"For HTTP servers","type":"object"},"health_check_interval":{"description":"Per-server discovery \u0026 health-check overrides (spec 074). Same *Duration\ntri-state as the global keys: nil = inherit the global value (or default),\npointer to 0s = disabled for this server, positive = that interval.\nHealthCheckInterval is fully wired into the per-server health loop;\nToolDiscoveryInterval is accepted/validated and round-trips for\nforward-compat, but the periodic index sweep is governed by the global\ncadence in this iteration (see spec 074 plan §C).","type":"string"},"init_timeout":{"description":"InitTimeout overrides the global init_timeout for this server's MCP\n` + "`" + `initialize` + "`" + ` handshake deadline (MCP-3322 / GH #760). *Duration tri-state:\nnil = inherit the global value (or 30s default), positive = that deadline.\nResolved by Config.ResolveInitTimeout; validated to {0} ∪ [1s, 30m]. Raise\nthis for upstreams that do legitimate first-run warmup (e.g. caching many\nchannels/users) before responding to ` + "`" + `initialize` + "`" + `.","type":"string"},"isolation":{"$ref":"#/components/schemas/config.IsolationConfig"},"launcher_wait_timeout":{"description":"LauncherWaitTimeout caps how long mcpproxy will wait for a locally-launched\nHTTP/SSE upstream's URL to become reachable after Spawn(). Only consulted\nwhen the server is configured with both Command and an HTTP/SSE URL — i.e.,\nmcpproxy starts the process AND connects via network. Stdio servers ignore\nthis field. Zero or unset → 30s default.","type":"string"},"name":{"type":"string"},"oauth":{"$ref":"#/components/schemas/config.OAuthConfig"},"protocol":{"description":"stdio, http, sse, streamable-http, auto","type":"string"},"quarantined":{"description":"Security quarantine status","type":"boolean"},"reconnect_on_use":{"description":"Attempt reconnection when a tool call targets a disconnected server","type":"boolean"},"shared":{"description":"Server edition: shared with all users","type":"boolean"},"skip_quarantine":{"description":"SkipQuarantine is DEPRECATED (MCP-2930): use AutoApproveToolChanges instead.\nKept for back-compat parsing; on config load a legacy skip_quarantine:true is\nmigrated to auto_approve_tool_changes:true only when the new field is unset\n(see normalizeServerQuarantineFlags).","type":"boolean"},"source_registry_id":{"description":"SourceRegistryID records which registry this server was added from (empty\nfor manually-configured servers). MCP-866: surfaced in the approval /\nquarantine view so a reviewer can see a server's origin.","type":"string"},"source_registry_provenance":{"description":"SourceRegistryProvenance records the source registry's provenance at add\ntime (RegistryProvenanceOfficial / RegistryProvenanceCustom). It is purely\ninformational (MCP-1072) — surfaced so a reviewer can see a server's origin\n— and no longer gates quarantine or skip_quarantine.","type":"string"},"tool_discovery_interval":{"type":"string"},"toon_output":{"description":"ToonOutput overrides the global toon_output mode for this server's\ntools (spec 084, FR-001). Plain string, not a pointer: \"\"/absent =\ninherit the global value; \"off\"|\"adaptive\"|\"always\" = override (\"off\"\nis the explicit force-off). Resolved by Config.ResolveToonOutput.","type":"string"},"trust_mode":{"description":"TrustMode is the per-server trust tier: auto|scan|manual. Supersedes\nauto_approve_tool_changes (spec 086). An empty value is derived from the\nlegacy fields at load via normalizeServerQuarantineFlags; the single\nresolution point is EffectiveTrustMode(), which treats an empty or\nunrecognized value as manual (secure by default). Read via\nEffectiveTrustMode(), never the raw string.","type":"string"},"updated":{"type":"string"},"url":{"type":"string"},"working_dir":{"description":"Working directory for stdio servers","type":"string"}},"type":"object"},"config.TLSConfig":{"description":"TLS configuration","properties":{"certs_dir":{"description":"Directory for certificates","type":"string"},"enabled":{"description":"Enable HTTPS","type":"boolean"},"hsts":{"description":"Enable HTTP Strict Transport Security","type":"boolean"},"require_client_cert":{"description":"Enable mTLS","type":"boolean"}},"type":"object"},"config.TelemetryConfig":{"description":"Telemetry settings (Spec 036)","properties":{"anonymous_id":{"description":"Auto-generated UUIDv4","type":"string"},"anonymous_id_created_at":{"description":"Spec 042 (Tier 2) additions — all default-zero, all backwards-compatible.","type":"string"},"enabled":{"description":"Default: true (opt-out)","type":"boolean"},"endpoint":{"description":"Override for testing","type":"string"},"last_reported_version":{"description":"Upgrade funnel","type":"string"},"last_startup_outcome":{"description":"success|port_conflict|db_locked|...","type":"string"},"notice_shown":{"description":"First-run notice flag","type":"boolean"}},"type":"object"},"config.TokenizerConfig":{"description":"Tokenizer configuration for token counting","properties":{"default_model":{"description":"Default model for tokenization (e.g., \"gpt-4\")","type":"string"},"enabled":{"description":"Enable token counting","type":"boolean"},"encoding":{"description":"Default encoding (e.g., \"cl100k_base\")","type":"string"}},"type":"object"},"config.TracingExporterConfig":{"description":"Tracing gates the OpenTelemetry OTLP trace exporter (MCP-32). Disabled by\ndefault.","properties":{"enabled":{"description":"Enabled turns on OTLP trace export for tool calls and upstream hops.","type":"boolean"},"endpoint":{"description":"Endpoint is the collector address as host:port (no scheme), e.g.\n\"localhost:4318\" for http or \"localhost:4317\" for grpc.","type":"string"},"protocol":{"description":"Protocol selects the OTLP transport: \"http\" or \"grpc\".","type":"string"},"sample_rate":{"description":"SampleRate is the head-based trace sampling ratio in [0,1]. Default 0.1.","type":"number"}},"type":"object"},"config.UpdateCheckConfig":{"description":"Update-check settings (Spec 079 FR-012): config-file control of the\nbackground upgrade-awareness checker (internal/updatecheck). nil =\nenabled on the stable channel (existing default behavior). The existing\nenvironment switches keep working and WIN over these keys (FR-014):\nMCPPROXY_DISABLE_AUTO_UPDATE=true force-disables even when\nenabled=true, and MCPPROXY_ALLOW_PRERELEASE_UPDATES=true force-selects\nthe rc channel even when channel=stable.","properties":{"channel":{"description":"Channel selects which releases are offered as updates: \"stable\"\n(default; prereleases never offered) or \"rc\" (prereleases included).\nEmpty resolves to stable. Validated in ValidateDetailed.","type":"string"},"enabled":{"description":"Enabled gates all update checking. Tri-state: nil/absent = enabled\n(default true, matching pre-079 behavior). When false, no network\ncheck is performed and no upgrade nudge appears on any surface\n(FR-015) — /api/v1/info omits the update object entirely.","type":"boolean"}},"type":"object"},"configimport.FailedServer":{"properties":{"details":{"type":"string"},"error":{"type":"string"},"name":{"type":"string"}},"type":"object"},"configimport.ImportSummary":{"properties":{"failed":{"type":"integer"},"imported":{"type":"integer"},"skipped":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"configimport.SkippedServer":{"properties":{"name":{"type":"string"},"reason":{"description":"\"already_exists\", \"filtered_out\", \"invalid_name\"","type":"string"}},"type":"object"},"connect.ConnectResult":{"description":"The full result; its action mirrors the top-level one","properties":{"action":{"description":"\"created\", \"updated\", \"already_exists\", \"removed\", \"not_found\"","type":"string"},"backup_path":{"type":"string"},"client":{"type":"string"},"config_path":{"type":"string"},"message":{"type":"string"},"server_name":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.APIResponse":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.ActivityDetailResponse":{"properties":{"activity":{"$ref":"#/components/schemas/contracts.ActivityRecord"}},"type":"object"},"contracts.ActivityListResponse":{"properties":{"activities":{"items":{"$ref":"#/components/schemas/contracts.ActivityRecord"},"type":"array","uniqueItems":false},"limit":{"type":"integer"},"offset":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"contracts.ActivityRecord":{"properties":{"arguments":{"description":"Tool call arguments","type":"object"},"detection_types":{"description":"List of detection types found","items":{"type":"string"},"type":"array","uniqueItems":false},"duration_ms":{"description":"Execution duration in milliseconds","type":"integer"},"error_message":{"description":"Error details if status is \"error\"","type":"string"},"has_sensitive_data":{"description":"Sensitive data detection fields (Spec 026)","type":"boolean"},"id":{"description":"Unique identifier (ULID format)","type":"string"},"max_severity":{"description":"Highest severity level detected (critical, high, medium, low)","type":"string"},"metadata":{"description":"Additional context-specific data","type":"object"},"request_id":{"description":"HTTP request ID for correlation","type":"string"},"response":{"description":"Tool response (potentially truncated)","type":"string"},"response_truncated":{"description":"True if response was truncated","type":"boolean"},"server_name":{"description":"Name of upstream MCP server","type":"string"},"session_id":{"description":"MCP transport session ID (regenerated on every reconnect)","type":"string"},"source":{"$ref":"#/components/schemas/contracts.ActivitySource"},"status":{"description":"Result status: \"success\", \"error\", \"blocked\"","type":"string"},"timestamp":{"description":"When activity occurred","type":"string"},"tool_name":{"description":"Name of tool called","type":"string"},"type":{"$ref":"#/components/schemas/contracts.ActivityType"},"work_session_id":{"description":"Spec 082: one client, one project, across reconnects","type":"string"}},"type":"object"},"contracts.ActivitySource":{"description":"How activity was triggered: \"mcp\", \"cli\", \"api\"","type":"string","x-enum-varnames":["ActivitySourceMCP","ActivitySourceCLI","ActivitySourceAPI"]},"contracts.ActivitySummaryResponse":{"properties":{"blocked_count":{"description":"Count of blocked activities","type":"integer"},"end_time":{"description":"End of the period (RFC3339)","type":"string"},"error_count":{"description":"Count of error activities","type":"integer"},"period":{"description":"Time period (1h, 24h, 7d, 30d)","type":"string"},"start_time":{"description":"Start of the period (RFC3339)","type":"string"},"success_count":{"description":"Count of successful activities","type":"integer"},"top_servers":{"description":"Top servers by activity count","items":{"$ref":"#/components/schemas/contracts.ActivityTopServer"},"type":"array","uniqueItems":false},"top_tools":{"description":"Top tools by activity count","items":{"$ref":"#/components/schemas/contracts.ActivityTopTool"},"type":"array","uniqueItems":false},"total_count":{"description":"Total activity count","type":"integer"}},"type":"object"},"contracts.ActivityTopServer":{"properties":{"count":{"description":"Activity count","type":"integer"},"name":{"description":"Server name","type":"string"}},"type":"object"},"contracts.ActivityTopTool":{"properties":{"count":{"description":"Activity count","type":"integer"},"server":{"description":"Server name","type":"string"},"tool":{"description":"Tool name","type":"string"}},"type":"object"},"contracts.ActivityType":{"description":"Type of activity","type":"string","x-enum-varnames":["ActivityTypeToolCall","ActivityTypePolicyDecision","ActivityTypeQuarantineChange","ActivityTypeServerChange"]},"contracts.AddFromRegistryRequest":{"properties":{"enabled":{"description":"defaults to true when nil","type":"boolean"},"env":{"additionalProperties":{"type":"string"},"description":"overrides + required-input values","type":"object"},"name":{"description":"optional name override","type":"string"}},"type":"object"},"contracts.AddRegistrySourceRequest":{"properties":{"id":{"description":"derived from the host when empty","type":"string"},"name":{"description":"defaults to the id","type":"string"},"protocol":{"description":"defaults to modelcontextprotocol/registry","type":"string"},"url":{"description":"required https registry URL","type":"string"}},"type":"object"},"contracts.ConfigApplyResult":{"properties":{"applied_immediately":{"type":"boolean"},"changed_fields":{"items":{"type":"string"},"type":"array","uniqueItems":false},"requires_restart":{"type":"boolean"},"restart_reason":{"type":"string"},"success":{"type":"boolean"},"validation_errors":{"items":{"$ref":"#/components/schemas/contracts.ValidationError"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DCRStatus":{"properties":{"attempted":{"type":"boolean"},"error":{"type":"string"},"status_code":{"type":"integer"},"success":{"type":"boolean"}},"type":"object"},"contracts.DeepScanDescriptor":{"description":"DeepScan reports the opt-in \"deep scan\" layer status (Spec 077 US3),\nSEPARATELY from the baseline verdict above. Always emitted on a computed\nsummary — when deep scan is off (the default) it reports enabled=false\nplus any enabled-but-skipped Docker scanners. It never influences Status.","properties":{"available":{"type":"boolean"},"enabled":{"type":"boolean"},"ran":{"type":"boolean"},"scanners_failed":{"items":{"$ref":"#/components/schemas/contracts.DeepScanScannerFailure"},"type":"array","uniqueItems":false},"skipped_scanners":{"description":"SkippedScanners lists Docker scanners the user enabled that are skipped\nbecause security.deep_scan.enabled is false (informational).","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DeepScanScannerFailure":{"properties":{"id":{"type":"string"},"reason":{"type":"string"}},"type":"object"},"contracts.DeprecatedConfigWarning":{"properties":{"field":{"type":"string"},"message":{"type":"string"},"replacement":{"type":"string"}},"type":"object"},"contracts.Diagnostic":{"description":"Spec 044 — structured diagnostic error and stable error code. Both\nare populated when the server is in a failed state and the error\nhas been classified by internal/diagnostics. Healthy servers omit\nthese fields.","properties":{"cause":{"type":"string"},"code":{"type":"string"},"detected_at":{"type":"string"},"docs_url":{"type":"string"},"fix_steps":{"items":{"$ref":"#/components/schemas/contracts.DiagnosticFixStep"},"type":"array","uniqueItems":false},"severity":{"type":"string"},"user_message":{"type":"string"}},"type":"object"},"contracts.DiagnosticFixStep":{"properties":{"command":{"type":"string"},"destructive":{"type":"boolean"},"fixer_key":{"type":"string"},"label":{"type":"string"},"type":{"type":"string"},"url":{"type":"string"}},"type":"object"},"contracts.Diagnostics":{"properties":{"deprecated_configs":{"description":"Deprecated config fields found","items":{"$ref":"#/components/schemas/contracts.DeprecatedConfigWarning"},"type":"array","uniqueItems":false},"docker_status":{"$ref":"#/components/schemas/contracts.DockerStatus"},"missing_secrets":{"description":"Renamed to avoid conflict","items":{"$ref":"#/components/schemas/contracts.MissingSecretInfo"},"type":"array","uniqueItems":false},"oauth_issues":{"description":"OAuth parameter mismatches","items":{"$ref":"#/components/schemas/contracts.OAuthIssue"},"type":"array","uniqueItems":false},"oauth_required":{"items":{"$ref":"#/components/schemas/contracts.OAuthRequirement"},"type":"array","uniqueItems":false},"runtime_warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false},"timestamp":{"type":"string"},"total_issues":{"type":"integer"},"upstream_errors":{"items":{"$ref":"#/components/schemas/contracts.UpstreamError"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DockerStatus":{"properties":{"available":{"type":"boolean"},"error":{"type":"string"},"version":{"type":"string"}},"type":"object"},"contracts.EditRegistrySourceRequest":{"properties":{"name":{"description":"new display name","type":"string"},"servers_url":{"description":"explicit servers-collection URL","type":"string"},"url":{"description":"new base/servers https URL","type":"string"}},"type":"object"},"contracts.ErrorResponse":{"properties":{"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.FindingCounts":{"properties":{"dangerous":{"description":"Tool poisoning, active prompt injection","type":"integer"},"info":{"description":"Low-severity CVEs, informational","type":"integer"},"total":{"type":"integer"},"warning":{"description":"Rug pull, supply chain CVEs with exploits","type":"integer"}},"type":"object"},"contracts.GetConfigResponse":{"properties":{"config":{"description":"The configuration object","type":"object"},"config_path":{"description":"Path to config file","type":"string"}},"type":"object"},"contracts.GetRegistriesResponse":{"properties":{"registries":{"items":{"$ref":"#/components/schemas/contracts.Registry"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetServerLogsResponse":{"properties":{"count":{"type":"integer"},"logs":{"items":{"$ref":"#/components/schemas/contracts.LogEntry"},"type":"array","uniqueItems":false},"server_name":{"type":"string"}},"type":"object"},"contracts.GetServerToolCallsResponse":{"properties":{"server_name":{"type":"string"},"tool_calls":{"items":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetServerToolsResponse":{"properties":{"count":{"type":"integer"},"server_name":{"type":"string"},"tools":{"items":{"$ref":"#/components/schemas/contracts.Tool"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.GetServersResponse":{"properties":{"servers":{"items":{"$ref":"#/components/schemas/contracts.Server"},"type":"array","uniqueItems":false},"stats":{"$ref":"#/components/schemas/contracts.ServerStats"}},"type":"object"},"contracts.GetSessionDetailResponse":{"properties":{"session":{"$ref":"#/components/schemas/contracts.MCPSession"}},"type":"object"},"contracts.GetSessionsResponse":{"properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"sessions":{"items":{"$ref":"#/components/schemas/contracts.MCPSession"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetToolCallDetailResponse":{"properties":{"tool_call":{"$ref":"#/components/schemas/contracts.ToolCallRecord"}},"type":"object"},"contracts.GetToolCallsResponse":{"properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"tool_calls":{"items":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GlobalToolsResponse":{"properties":{"failed_servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"partial":{"type":"boolean"},"stats":{"$ref":"#/components/schemas/contracts.GlobalToolsStats"},"tools":{"items":{"$ref":"#/components/schemas/contracts.Tool"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.GlobalToolsStats":{"properties":{"disabled":{"type":"integer"},"enabled":{"type":"integer"},"pending_approval":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"contracts.HealthStatus":{"description":"Unified health status calculated by the backend","properties":{"action":{"description":"Action is the suggested fix action: \"login\", \"restart\", \"enable\", \"approve\", \"view_logs\", \"set_secret\", \"configure\", or \"\" (none)","type":"string"},"admin_state":{"description":"AdminState indicates the admin state: \"enabled\", \"disabled\", or \"quarantined\"","type":"string"},"detail":{"description":"Detail is an optional longer explanation of the status","type":"string"},"level":{"description":"Level indicates the health level: \"healthy\", \"degraded\", or \"unhealthy\"","type":"string"},"summary":{"description":"Summary is a human-readable status message (e.g., \"Connected (5 tools)\")","type":"string"}},"type":"object"},"contracts.InfoEndpoints":{"description":"Available API endpoints","properties":{"http":{"description":"HTTP endpoint address (e.g., \"127.0.0.1:8080\")","type":"string"},"socket":{"description":"Unix socket path (empty if disabled)","type":"string"}},"type":"object"},"contracts.InfoResponse":{"properties":{"endpoints":{"$ref":"#/components/schemas/contracts.InfoEndpoints"},"launched_by":{"description":"LaunchedBy is the durable launch provenance of the running core (Spec\n092 FR-001a): \"tray\" when a tray spawned it, \"installer\" when the macOS\nPKG postinstall did, \"\" when user-launched or unknown. Always present\n(possibly empty) so a tray can distinguish \"old core, not mine\" from\n\"old core I may supersede\".","type":"string"},"listen_addr":{"description":"Listen address (e.g., \"127.0.0.1:8080\")","type":"string"},"pid":{"description":"PID is the operating-system process id of the running core (Spec 092\nFR-002). A tray that merely ATTACHED to a core holds no Process handle\nfor it, so without this there is no mechanism at all to stop a stale\ncore — the consent action would have nothing to act on and could only\nprint instructions. Paired with LaunchedBy it is what lets a newer tray\nsupersede a core an older tray started.","type":"integer"},"update":{"$ref":"#/components/schemas/contracts.UpdateInfo"},"update_policy":{"$ref":"#/components/schemas/contracts.UpdatePolicy"},"version":{"description":"Current MCPProxy version","type":"string"},"web_ui_url":{"description":"URL to access the web control panel","type":"string"}},"type":"object"},"contracts.IsolationConfig":{"properties":{"cpu_limit":{"type":"string"},"enabled":{"type":"boolean"},"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"memory_limit":{"type":"string"},"network_mode":{"type":"string"},"timeout":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"contracts.IsolationDefaults":{"description":"IsolationDefaults exposes the resolved baseline values that\nwould apply when no per-server override is set. Populated on\nlist/get responses; never consumed on PATCH requests.","properties":{"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"network_mode":{"type":"string"},"runtime_type":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"contracts.LogEntry":{"properties":{"fields":{"type":"object"},"level":{"type":"string"},"message":{"type":"string"},"server":{"type":"string"},"timestamp":{"type":"string"}},"type":"object"},"contracts.MCPSession":{"properties":{"client_name":{"type":"string"},"client_version":{"type":"string"},"end_time":{"type":"string"},"experimental":{"items":{"type":"string"},"type":"array","uniqueItems":false},"has_roots":{"description":"MCP Client Capabilities","type":"boolean"},"has_sampling":{"type":"boolean"},"id":{"type":"string"},"last_activity":{"type":"string"},"start_time":{"type":"string"},"status":{"type":"string"},"tool_call_count":{"type":"integer"},"total_tokens":{"type":"integer"},"work_session_id":{"type":"string"},"workspace_name":{"description":"Workspace / work session (Spec 082). WorkspaceName is the project's\nbasename — the full local path is never exposed. WorkSessionID groups the\nreconnects that make up one stretch of user work.","type":"string"}},"type":"object"},"contracts.MetadataStatus":{"properties":{"authorization_servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"error":{"type":"string"},"found":{"type":"boolean"},"url_checked":{"type":"string"}},"type":"object"},"contracts.MissingSecretInfo":{"properties":{"secret_name":{"type":"string"},"used_by":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.NPMPackageInfo":{"properties":{"exists":{"type":"boolean"},"install_cmd":{"type":"string"}},"type":"object"},"contracts.OAuthConfig":{"properties":{"auth_url":{"type":"string"},"client_id":{"type":"string"},"extra_params":{"additionalProperties":{"type":"string"},"type":"object"},"pkce_enabled":{"type":"boolean"},"redirect_port":{"type":"integer"},"scopes":{"items":{"type":"string"},"type":"array","uniqueItems":false},"token_expires_at":{"description":"When the OAuth token expires","type":"string"},"token_url":{"type":"string"},"token_valid":{"description":"Whether token is currently valid","type":"boolean"}},"type":"object"},"contracts.OAuthErrorDetails":{"description":"Structured discovery/failure details","properties":{"authorization_server_metadata":{"$ref":"#/components/schemas/contracts.MetadataStatus"},"dcr_status":{"$ref":"#/components/schemas/contracts.DCRStatus"},"protected_resource_metadata":{"$ref":"#/components/schemas/contracts.MetadataStatus"},"server_url":{"type":"string"}},"type":"object"},"contracts.OAuthFlowError":{"properties":{"correlation_id":{"description":"Flow tracking ID for log correlation","type":"string"},"debug_hint":{"description":"CLI command for log lookup","type":"string"},"details":{"$ref":"#/components/schemas/contracts.OAuthErrorDetails"},"error_code":{"description":"Machine-readable error code (e.g., OAUTH_NO_METADATA)","type":"string"},"error_type":{"description":"Category of OAuth runtime failure","type":"string"},"message":{"description":"Human-readable error description","type":"string"},"request_id":{"description":"HTTP request ID (from PR #237)","type":"string"},"server_name":{"description":"Server that failed OAuth","type":"string"},"success":{"description":"Always false","type":"boolean"},"suggestion":{"description":"Actionable remediation hint","type":"string"}},"type":"object"},"contracts.OAuthIssue":{"properties":{"documentation_url":{"type":"string"},"error":{"type":"string"},"issue":{"type":"string"},"missing_params":{"items":{"type":"string"},"type":"array","uniqueItems":false},"resolution":{"type":"string"},"server_name":{"type":"string"}},"type":"object"},"contracts.OAuthRequirement":{"properties":{"expires_at":{"type":"string"},"message":{"type":"string"},"server_name":{"type":"string"},"state":{"type":"string"}},"type":"object"},"contracts.OAuthStartResponse":{"properties":{"auth_url":{"description":"Authorization URL (always included for manual use)","type":"string"},"browser_error":{"description":"Error message if browser launch failed","type":"string"},"browser_opened":{"description":"Whether browser launch succeeded","type":"boolean"},"correlation_id":{"description":"UUID for tracking this flow","type":"string"},"message":{"description":"Human-readable status message","type":"string"},"server_name":{"description":"Name of the server being authenticated","type":"string"},"success":{"description":"Always true for successful start","type":"boolean"}},"type":"object"},"contracts.QuarantineStats":{"description":"Tool quarantine metrics for this server","properties":{"blocked_count":{"description":"Number of disabled (blocked) tools","type":"integer"},"changed_count":{"description":"Number of tools whose description/schema changed since approval","type":"integer"},"pending_count":{"description":"Number of newly discovered tools awaiting approval","type":"integer"}},"type":"object"},"contracts.RefreshRegistryResponse":{"properties":{"cleared":{"description":"number of cached entries dropped","type":"integer"},"registry_id":{"type":"string"}},"type":"object"},"contracts.Registry":{"properties":{"count":{"description":"number or string","type":"string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"protocol":{"type":"string"},"provenance":{"description":"Provenance is the trust tag (MCP-866): \"official/trusted\" for built-in\ndefaults, \"custom/unverified\" for user-added registries.","type":"string"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"trusted":{"description":"Trusted indicates whether this is an official, shipped-by-default\nregistry. Trust is derived from membership in the default set, never\nfrom self-assertion in config.","type":"boolean"},"url":{"type":"string"}},"type":"object"},"contracts.RegistryCacheInfo":{"properties":{"age_seconds":{"type":"number"},"stale":{"type":"boolean"}},"type":"object"},"contracts.RegistryUnavailable":{"properties":{"reason":{"type":"string"}},"type":"object"},"contracts.ReplayToolCallRequest":{"properties":{"arguments":{"description":"Modified arguments for replay","type":"object"}},"type":"object"},"contracts.ReplayToolCallResponse":{"properties":{"error":{"description":"Error if replay failed","type":"string"},"new_call_id":{"description":"ID of the newly created call","type":"string"},"new_tool_call":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"replayed_from":{"description":"Original call ID","type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.RepositoryInfo":{"description":"Detected package info","properties":{"npm":{"$ref":"#/components/schemas/contracts.NPMPackageInfo"}},"type":"object"},"contracts.RepositoryServer":{"properties":{"connect_url":{"description":"Alternative connection URL","type":"string"},"created_at":{"type":"string"},"description":{"type":"string"},"id":{"type":"string"},"install_cmd":{"description":"Installation command","type":"string"},"name":{"type":"string"},"registry":{"description":"Which registry this came from","type":"string"},"repository_info":{"$ref":"#/components/schemas/contracts.RepositoryInfo"},"source_code_url":{"description":"Source repository URL","type":"string"},"updated_at":{"type":"string"},"url":{"description":"MCP endpoint for remote servers only","type":"string"}},"type":"object"},"contracts.SearchRegistryServersResponse":{"properties":{"cache":{"$ref":"#/components/schemas/contracts.RegistryCacheInfo"},"query":{"type":"string"},"registry_id":{"type":"string"},"servers":{"items":{"$ref":"#/components/schemas/contracts.RepositoryServer"},"type":"array","uniqueItems":false},"tag":{"type":"string"},"total":{"type":"integer"},"unavailable":{"$ref":"#/components/schemas/contracts.RegistryUnavailable"}},"type":"object"},"contracts.SearchResult":{"properties":{"matches":{"type":"integer"},"score":{"type":"number"},"snippet":{"type":"string"},"tool":{"$ref":"#/components/schemas/contracts.Tool"}},"type":"object"},"contracts.SearchToolsResponse":{"properties":{"query":{"type":"string"},"results":{"items":{"$ref":"#/components/schemas/contracts.SearchResult"},"type":"array","uniqueItems":false},"took":{"type":"string"},"total":{"type":"integer"}},"type":"object"},"contracts.SecurityScanSummary":{"description":"Latest security scan results summary","properties":{"deep_scan":{"$ref":"#/components/schemas/contracts.DeepScanDescriptor"},"finding_counts":{"$ref":"#/components/schemas/contracts.FindingCounts"},"last_scan_at":{"type":"string"},"risk_score":{"description":"0-100","type":"integer"},"scanners_failed":{"type":"integer"},"scanners_run":{"description":"Scanner coverage for the primary (baseline) scan pass — informational only.\nSpec 077 US3 (FR-008/FR-014): Status is derived SOLELY from the\ndeterministic baseline findings; a failed Docker deep scanner no longer\ndowngrades a clean verdict. That failure is surfaced via DeepScan instead.","type":"integer"},"scanners_total":{"type":"integer"},"status":{"description":"\"clean\", \"warnings\", \"dangerous\", \"failed\", \"not_scanned\", \"scanning\"","type":"string"}},"type":"object"},"contracts.Server":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"authenticated":{"description":"OAuth authentication status","type":"boolean"},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges mirrors config.ServerConfig.AutoApproveToolChanges\n(MCP-2930): the per-server intent to auto-approve new/changed tools past\nthe trust baseline. Tri-state *bool — nil means \"never set\" (omitted from\nthe payload), so the Web UI toggle (MCP-2932) can distinguish unset from\nan explicit false. Read-only on the GET path; PATCH/POST accept it via\nAddServerRequest.","type":"boolean"},"command":{"type":"string"},"connected":{"type":"boolean"},"connected_at":{"type":"string"},"connecting":{"type":"boolean"},"created":{"type":"string"},"diagnostic":{"$ref":"#/components/schemas/contracts.Diagnostic"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"error_code":{"type":"string"},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"health":{"$ref":"#/components/schemas/contracts.HealthStatus"},"id":{"type":"string"},"init_timeout":{"description":"InitTimeout mirrors config.ServerConfig.InitTimeout (MCP-3322 / GH #760):\nthe per-server MCP ` + "`" + `initialize` + "`" + ` handshake deadline override. Serialized as\na duration string (e.g. \"120s\"); nil/omitted means \"inherit the global\ndefault\". Surfaced on the GET path so clients can read back a configured\noverride; PATCH/POST accept it via AddServerRequest.","type":"string"},"isolation":{"$ref":"#/components/schemas/contracts.IsolationConfig"},"isolation_defaults":{"$ref":"#/components/schemas/contracts.IsolationDefaults"},"last_error":{"type":"string"},"last_reconnect_at":{"type":"string"},"last_retry_time":{"type":"string"},"name":{"type":"string"},"oauth":{"$ref":"#/components/schemas/contracts.OAuthConfig"},"oauth_status":{"description":"OAuth status: \"authenticated\", \"expired\", \"error\", \"none\"","type":"string"},"protocol":{"type":"string"},"quarantine":{"$ref":"#/components/schemas/contracts.QuarantineStats"},"quarantined":{"type":"boolean"},"reconnect_count":{"type":"integer"},"reconnect_on_use":{"description":"Attempt reconnection when a tool call targets this disconnected server","type":"boolean"},"retry_count":{"type":"integer"},"security_scan":{"$ref":"#/components/schemas/contracts.SecurityScanSummary"},"should_retry":{"type":"boolean"},"source_registry_id":{"description":"MCP-901 — registry provenance of an upstream that was added from a\nregistry. SourceRegistryID names the source registry (empty for\nmanually-configured servers); SourceRegistryProvenance is the trust tag\nrecorded at add time (\"official/trusted\" or \"custom/unverified\"). Both\nare projected from config.ServerConfig so the approval/quarantine view\ncan render an \"added from \u003cregistry\u003e · unverified\" origin badge. Optional\nand omitted when empty — clients that pre-date this treat them as absent.","type":"string"},"source_registry_provenance":{"type":"string"},"status":{"type":"string"},"token_expires_at":{"description":"When the OAuth token expires (ISO 8601)","type":"string"},"tool_count":{"type":"integer"},"tool_list_token_size":{"description":"Token size for this server's tools","type":"integer"},"trust_mode":{"description":"TrustMode mirrors config.ServerConfig.TrustMode (spec 086): the per-server\ntrust tier (\"auto\"/\"scan\"/\"manual\"). Surfaced on the GET path so clients can\nread back the persisted mode; PATCH/POST accept it via AddServerRequest.\nOmitted when empty (server predates the field / relies on legacy flags).","type":"string"},"updated":{"type":"string"},"url":{"type":"string"},"user_logged_out":{"description":"True if user explicitly logged out (prevents auto-reconnection)","type":"boolean"},"working_dir":{"type":"string"}},"type":"object"},"contracts.ServerActionResponse":{"properties":{"action":{"type":"string"},"async":{"type":"boolean"},"server":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.ServerStats":{"properties":{"connected_servers":{"type":"integer"},"docker_containers":{"type":"integer"},"quarantined_servers":{"type":"integer"},"token_metrics":{"$ref":"#/components/schemas/contracts.ServerTokenMetrics"},"total_servers":{"type":"integer"},"total_tools":{"type":"integer"}},"type":"object"},"contracts.ServerTokenMetrics":{"properties":{"average_query_result_size":{"description":"Typical retrieve_tools output (tokens)","type":"integer"},"per_server_tool_list_sizes":{"additionalProperties":{"type":"integer"},"description":"Token size per server","type":"object"},"saved_tokens":{"description":"Difference","type":"integer"},"saved_tokens_percentage":{"description":"Percentage saved","type":"number"},"total_server_tool_list_size":{"description":"All upstream tools combined (tokens)","type":"integer"}},"type":"object"},"contracts.SuccessResponse":{"properties":{"data":{"type":"object"},"success":{"type":"boolean"}},"type":"object"},"contracts.TokenMetrics":{"description":"Token usage metrics (nil for older records)","properties":{"encoding":{"description":"Encoding used (e.g., cl100k_base)","type":"string"},"estimated_cost":{"description":"Optional cost estimate","type":"number"},"input_tokens":{"description":"Tokens in the request","type":"integer"},"model":{"description":"Model used for tokenization","type":"string"},"output_tokens":{"description":"Tokens in the response","type":"integer"},"total_tokens":{"description":"Total tokens (input + output)","type":"integer"},"truncated_tokens":{"description":"Tokens removed by truncation","type":"integer"},"was_truncated":{"description":"Whether response was truncated","type":"boolean"}},"type":"object"},"contracts.Tool":{"properties":{"annotations":{"$ref":"#/components/schemas/contracts.ToolAnnotation"},"approval_status":{"type":"string"},"config_denied":{"description":"ConfigDenied is true when the tool is denied by the server's static\nenabled_tools / disabled_tools config. The user cannot override this toggle.","type":"boolean"},"description":{"type":"string"},"disabled":{"description":"Disabled mirrors ToolApprovalRecord.Disabled so per-tool enable state is\navailable without a second round-trip to the approvals endpoint. Absent\nin the JSON when false (default) to keep responses compact.","type":"boolean"},"held_reason":{"description":"HeldReason, HeldVerdict and HeldSignals mirror the same-named fields on\nstorage.ToolApprovalRecord: the offline-scan evidence that made\ntrust_mode: scan hold this tool for review (spec 086 FR-018). HeldSignals\nnames the matched deterministic check ids, e.g.\n\"tpa.TPA-2026-0001.hidden_instruction\", so a reviewer can see WHY the tool\nis held. All three are omitted for tools that are not held by the scan gate\n(including every record written before the field existed).","type":"string"},"held_signals":{"items":{"type":"string"},"type":"array","uniqueItems":false},"held_verdict":{"type":"string"},"last_used":{"type":"string"},"name":{"type":"string"},"schema":{"type":"object"},"server_name":{"type":"string"},"usage":{"type":"integer"}},"type":"object"},"contracts.ToolAnnotation":{"description":"Tool behavior hints snapshot","properties":{"destructiveHint":{"type":"boolean"},"idempotentHint":{"type":"boolean"},"openWorldHint":{"type":"boolean"},"readOnlyHint":{"type":"boolean"},"title":{"type":"string"}},"type":"object"},"contracts.ToolCallRecord":{"description":"The new tool call record","properties":{"annotations":{"$ref":"#/components/schemas/contracts.ToolAnnotation"},"arguments":{"description":"Tool arguments","type":"object"},"config_path":{"description":"Active config file path","type":"string"},"duration":{"description":"Duration in nanoseconds","type":"integer"},"error":{"description":"Error message (failure only)","type":"string"},"execution_type":{"description":"\"direct\" or \"code_execution\"","type":"string"},"id":{"description":"Unique identifier","type":"string"},"mcp_client_name":{"description":"MCP client name from InitializeRequest","type":"string"},"mcp_client_version":{"description":"MCP client version","type":"string"},"mcp_session_id":{"description":"MCP session identifier","type":"string"},"metrics":{"$ref":"#/components/schemas/contracts.TokenMetrics"},"parent_call_id":{"description":"Links nested calls to parent code_execution","type":"string"},"request_id":{"description":"Request correlation ID","type":"string"},"response":{"description":"Tool response (success only)","type":"object"},"server_id":{"description":"Server identity hash","type":"string"},"server_name":{"description":"Human-readable server name","type":"string"},"timestamp":{"description":"When the call was made","type":"string"},"tool_name":{"description":"Tool name (without server prefix)","type":"string"}},"type":"object"},"contracts.UpdateInfo":{"description":"Update information (if available)","properties":{"available":{"description":"Whether an update is available","type":"boolean"},"check_error":{"description":"Error message if update check failed","type":"string"},"checked_at":{"description":"When the update check was performed","type":"string"},"install_channel":{"description":"Detected install channel (homebrew, dmg, deb, rpm, docker, go-install, windows-installer, tarball, unknown) — Spec 079 FR-008","type":"string"},"is_prerelease":{"description":"Whether the latest version is a prerelease","type":"boolean"},"latest_version":{"description":"Latest version available (e.g., \"v1.2.3\")","type":"string"},"nudges_suppressed":{"description":"UI surfaces must stay quiet (CI / non-interactive context); machine-readable fields still report the facts — Spec 079 FR-019","type":"boolean"},"release_url":{"description":"URL to the release page","type":"string"},"update_command":{"description":"One-line update command for the channel; only set when an update is available and the channel has one — Spec 079 FR-009","type":"string"}},"type":"object"},"contracts.UpdatePolicy":{"description":"UpdatePolicy is the effective, hot-reloadable update policy (Spec 092\nFR-015). Always present: the ` + "`" + `update` + "`" + ` object above is omitted both when\nupdate checking is disabled AND when no check has produced a result\nyet, so its absence cannot tell a client whether it is allowed to run\nits own (e.g. Sparkle feed) check. This field states the answer.","properties":{"channel":{"description":"Channel is the tracked release channel: \"stable\" or \"rc\".","type":"string"},"enabled":{"description":"Enabled is the effective automatic-check kill switch: update_check.enabled\nwith MCPPROXY_DISABLE_AUTO_UPDATE=true winning over it. A user-initiated\n\"Check for Updates\" stays available regardless.","type":"boolean"},"nudges_suppressed":{"description":"NudgesSuppressed asks UI surfaces to stay quiet (CI / non-interactive)\nwhile machine-readable fields keep reporting the facts.","type":"boolean"}},"type":"object"},"contracts.UpstreamError":{"properties":{"error_message":{"type":"string"},"server_name":{"type":"string"},"timestamp":{"type":"string"}},"type":"object"},"contracts.UsageAggregateResponse":{"properties":{"freshness_ms":{"description":"age of the underlying snapshot in ms","type":"integer"},"generated_at":{"type":"string"},"other":{"$ref":"#/components/schemas/contracts.UsageOtherBucket"},"timeline":{"items":{"$ref":"#/components/schemas/contracts.UsageTimeBucket"},"type":"array","uniqueItems":false},"token_source":{"description":"\"bytes\" (size-based proxy, FR-006)","type":"string"},"tokens_saved":{"description":"echoed from ServerTokenMetrics (FR-007)","type":"integer"},"tokens_saved_percentage":{"type":"number"},"tools":{"items":{"$ref":"#/components/schemas/contracts.UsageToolStat"},"type":"array","uniqueItems":false},"window":{"type":"string"}},"type":"object"},"contracts.UsageOtherBucket":{"description":"present only when the list was truncated to top-N","properties":{"calls":{"type":"integer"},"tools_folded":{"type":"integer"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.UsageTimeBucket":{"properties":{"calls":{"type":"integer"},"errors":{"type":"integer"},"start":{"type":"string"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.UsageToolStat":{"properties":{"avg_req_bytes":{"description":"null when no sized request calls","type":"integer"},"avg_resp_bytes":{"description":"null when sized_calls == 0 (only legacy 0-byte calls)","type":"integer"},"blocked":{"type":"integer"},"calls":{"type":"integer"},"error_rate":{"type":"number"},"errors":{"type":"integer"},"last_used":{"type":"string"},"p50_ms":{"type":"integer"},"p95_ms":{"type":"integer"},"server":{"type":"string"},"sized_calls":{"description":"calls with known response size (basis for avg_resp_bytes)","type":"integer"},"tool":{"type":"string"},"total_req_bytes":{"type":"integer"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.ValidateConfigResponse":{"properties":{"errors":{"items":{"$ref":"#/components/schemas/contracts.ValidationError"},"type":"array","uniqueItems":false},"valid":{"type":"boolean"}},"type":"object"},"contracts.ValidationError":{"properties":{"field":{"type":"string"},"message":{"type":"string"}},"type":"object"},"data":{"properties":{"data":{"$ref":"#/components/schemas/contracts.InfoResponse"}},"type":"object"},"httpapi.AddServerRequest":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges is the per-server intent to auto-approve\nnew/changed tools past the trust baseline (MCP-2930). Tri-state *bool:\na nil pointer means \"leave unchanged\" on PATCH; a present value\n(including false) is applied. Mirrors config.ServerConfig's *bool\nsemantics — do NOT collapse to a plain bool, or an omitted field would\nsilently reset a previously-set value.","type":"boolean"},"command":{"type":"string"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"init_timeout":{"description":"InitTimeout is the per-server MCP ` + "`" + `initialize` + "`" + ` handshake deadline override\n(MCP-3322 / GH #760), serialized as a duration string (e.g. \"120s\"). A nil\npointer means \"leave unchanged\" on PATCH; a present value is applied.\nMirrors config.ServerConfig.InitTimeout's *Duration tri-state.","type":"string"},"isolation":{"$ref":"#/components/schemas/httpapi.IsolationRequest"},"name":{"type":"string"},"protocol":{"type":"string"},"quarantined":{"type":"boolean"},"reconnect_on_use":{"type":"boolean"},"trust_mode":{"description":"TrustMode is the per-server trust tier (spec 086): \"auto\", \"scan\", or\n\"manual\". Empty means \"leave unchanged\" on PATCH (and inherit the migrated\ndefault on create). A non-empty value is applied to ServerConfig.TrustMode\nand resolved by EffectiveTrustMode (an unrecognized value fails closed to\nmanual). This is the REST seam for changing the trust tier via\nPOST/PATCH /api/v1/servers.","type":"string"},"url":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"httpapi.CanonicalConfigPath":{"properties":{"description":{"description":"Brief description","type":"string"},"exists":{"description":"Whether the file exists","type":"boolean"},"format":{"description":"Format identifier (e.g., \"claude_desktop\")","type":"string"},"name":{"description":"Display name (e.g., \"Claude Desktop\")","type":"string"},"os":{"description":"Operating system (darwin, windows, linux)","type":"string"},"path":{"description":"Full path to the config file","type":"string"}},"type":"object"},"httpapi.CanonicalConfigPathsResponse":{"properties":{"os":{"description":"Current operating system","type":"string"},"paths":{"description":"List of canonical config paths","items":{"$ref":"#/components/schemas/httpapi.CanonicalConfigPath"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ConnectConflictResponse":{"properties":{"action":{"description":"already_exists | precondition_failed","type":"string"},"data":{"$ref":"#/components/schemas/connect.ConnectResult"},"error":{"description":"Human-readable message","type":"string"},"success":{"description":"Always false","type":"boolean"}},"type":"object"},"httpapi.ConnectRequest":{"properties":{"force":{"description":"Overwrite existing entry","type":"boolean"},"precondition_token":{"description":"PreconditionToken is the opaque token from the preview this write was\nconfirmed against (Spec 091 FR-005). When present, the core rechecks it\nat write time and responds 409 with action \"precondition_failed\" —\nwriting nothing — if the config or the entry MCPProxy would write has\ndrifted since; the caller then re-previews instead of retrying. Absent\nmeans exactly the pre-091 behavior. A replace-classified flow sends this\nTOGETHER with force=true: the token, not the absence of force, is the\noverwrite safety.","type":"string"},"server_name":{"description":"Defaults to \"mcpproxy\"","type":"string"}},"type":"object"},"httpapi.ImportFromPathRequest":{"properties":{"format":{"description":"Optional format hint","type":"string"},"path":{"description":"File path to import from","type":"string"},"rename":{"additionalProperties":{"type":"string"},"description":"Rename maps a server name → new name. Applied after parsing so the\ncaller can disambiguate cross-source name collisions (Spec 046 v2 —\ne.g. \"mcpproxy\" → \"mcpproxy_claude_code\"). Keys are matched against\neither the raw source name (OriginalName) or the sanitized name shown\nin the preview (Server.Name); these differ for names that need\nsanitizing (e.g. \"Figma Desktop\" → \"Figma_Desktop\"). Keys not present\nin the imported set are ignored.","type":"object"},"server_names":{"description":"Optional: import only these servers","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportRequest":{"properties":{"content":{"description":"Raw JSON or TOML content","type":"string"},"format":{"description":"Optional format hint","type":"string"},"server_names":{"description":"Optional: import only these servers","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportResponse":{"properties":{"failed":{"items":{"$ref":"#/components/schemas/configimport.FailedServer"},"type":"array","uniqueItems":false},"format":{"type":"string"},"format_name":{"type":"string"},"imported":{"items":{"$ref":"#/components/schemas/httpapi.ImportedServerResponse"},"type":"array","uniqueItems":false},"skipped":{"items":{"$ref":"#/components/schemas/configimport.SkippedServer"},"type":"array","uniqueItems":false},"summary":{"$ref":"#/components/schemas/configimport.ImportSummary"},"warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportedServerResponse":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"command":{"type":"string"},"fields_skipped":{"items":{"type":"string"},"type":"array","uniqueItems":false},"name":{"type":"string"},"original_name":{"type":"string"},"protocol":{"type":"string"},"source_format":{"type":"string"},"url":{"type":"string"},"warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.IsolationRequest":{"description":"Isolation carries per-server Docker isolation overrides (image,\nnetwork_mode, extra_args, working_dir, enabled). A nil pointer\nmeans \"do not touch isolation config\"; an empty-but-present\nobject on PATCH intentionally clears the overrides.","properties":{"enabled":{"type":"boolean"},"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"network_mode":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"httpapi.OnboardingMarkRequest":{"properties":{"connect_step_status":{"description":"ConnectStepStatus is one of: \"\", \"completed\", \"skipped\". Empty\npreserves the existing value. The stored enum is wider (Spec 080\nFR-001): a \"skipped\" request for a previously untouched connect step\nis upgraded server-side to \"completed_external\" when the install\nshows positive evidence of an external connection (Spec 080 FR-002).\n\"completed_external\" is NOT accepted from clients — it must never be\npersisted without that server-verified evidence (edge case: \"never\nguess completed_external without positive evidence\").","type":"string"},"engaged":{"description":"Engaged marks the wizard as engaged (completed or explicitly skipped).\nOnce true, the wizard does not auto-show again.","type":"boolean"},"mark_shown":{"description":"MarkShown records the wizard's first display time if not already set.","type":"boolean"},"server_step_status":{"description":"ServerStepStatus is one of: \"\", \"completed\", \"skipped\". Empty\npreserves the existing value.","type":"string"}},"type":"object"},"httpapi.SetActiveProfileRequest":{"properties":{"active_profile":{"type":"string"},"profile":{"type":"string"}},"type":"object"},"httpapi.UndoConnectRequest":{"properties":{"backup_name":{"description":"BackupName is the bare filename (filepath.Base) of the backup returned as\nbackup_path by the preceding connect — a name, never a path. Undo resolves\nthe full path server-side by joining it with the client's own config\ndirectory, so a client-supplied value can never contribute a directory\ncomponent (traversal is impossible by construction). Empty means the\nconnect created the file (no prior file existed), so undo removes it.","type":"string"},"server_name":{"description":"Defaults to \"mcpproxy\"","type":"string"}},"type":"object"},"management.BulkOperationResult":{"properties":{"errors":{"additionalProperties":{"type":"string"},"description":"Map of server name to error message","type":"object"},"failed":{"description":"Number of failed operations","type":"integer"},"successful":{"description":"Number of successful operations","type":"integer"},"total":{"description":"Total servers processed","type":"integer"}},"type":"object"},"observability.HealthResponse":{"properties":{"components":{"items":{"$ref":"#/components/schemas/observability.HealthStatus"},"type":"array","uniqueItems":false},"status":{"description":"\"healthy\" or \"unhealthy\"","type":"string"},"timestamp":{"type":"string"}},"type":"object"},"observability.HealthStatus":{"properties":{"error":{"type":"string"},"latency":{"type":"string"},"name":{"type":"string"},"status":{"description":"\"healthy\" or \"unhealthy\"","type":"string"}},"type":"object"},"observability.ReadinessResponse":{"properties":{"components":{"items":{"$ref":"#/components/schemas/observability.HealthStatus"},"type":"array","uniqueItems":false},"status":{"description":"\"ready\" or \"not_ready\"","type":"string"},"timestamp":{"type":"string"}},"type":"object"},"secureenv.EnvConfig":{"description":"Environment configuration for secure variable filtering","properties":{"allowed_system_vars":{"items":{"type":"string"},"type":"array","uniqueItems":false},"custom_vars":{"additionalProperties":{"type":"string"},"type":"object"},"enhance_path":{"description":"Enable PATH enhancement for Launchd scenarios","type":"boolean"},"forward_proxy_env":{"description":"ForwardProxyEnv opts in to forwarding the ambient HTTP(S)/ALL/NO/FTP proxy\nenvironment variables to spawned upstream servers (MCP-2769). It is OFF by\ndefault and deliberately kept out of the AllowedSystemVars default list:\nproxy URLs frequently carry credentials (http://user:pass@proxy), so\nforwarding them to every stdio upstream is a credential-leak risk. When\nenabled, values are forwarded with their userinfo (credentials) redacted.","type":"boolean"},"inherit_system_safe":{"type":"boolean"}},"type":"object"},"telemetry.FeedbackContext":{"properties":{"arch":{"type":"string"},"connected_server_count":{"type":"integer"},"edition":{"type":"string"},"os":{"type":"string"},"routing_mode":{"type":"string"},"server_count":{"type":"integer"},"version":{"type":"string"}},"type":"object"},"telemetry.FeedbackRequest":{"properties":{"category":{"description":"bug, feature, other","type":"string"},"context":{"$ref":"#/components/schemas/telemetry.FeedbackContext"},"email":{"type":"string"},"message":{"type":"string"}},"type":"object"},"telemetry.FeedbackResponse":{"properties":{"error":{"type":"string"},"issue_url":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}},"securitySchemes":{"ApiKeyAuth":{"description":"API key authentication via query parameter. Use ?apikey=your-key","in":"query","name":"apikey","type":"apiKey"}}},
+ "components": {"schemas":{"config.ConcurrencyDefaults":{"description":"ServerConcurrencyDefaults is scope (b) of FR-020: the blanket per-server\ndefault set inherited by every server that does not override a setting.\nAbsent (the default) = no per-server limiting unless a server configures\nit explicitly. File/API-configured only — no env scheme (FR-022).","properties":{"max_concurrent_requests":{"type":"integer"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"}},"type":"object"},"config.Config":{"properties":{"activity_cleanup_interval_min":{"description":"Background cleanup interval in minutes (default: 60)","type":"integer"},"activity_max_records":{"description":"Max records before pruning (default: 100000)","type":"integer"},"activity_max_response_size":{"description":"Response truncation limit in bytes (default: 65536)","type":"integer"},"activity_max_size_mb":{"description":"Max total activity-log size in MB before pruning oldest (default: 256, 0=disabled)","type":"integer"},"activity_retention_days":{"description":"Activity logging settings (RFC-003)","type":"integer"},"allow_private_registry_fetch":{"description":"AllowPrivateRegistryFetch opts out of the registry SSRF guard (MCP-1076,\nCWE-918). By default (false) registry fetches refuse any host that is — or\nresolves to — a non-routable address (loopback, RFC1918/CGNAT private,\nlink-local incl. the 169.254.169.254 cloud-metadata endpoint), so a\nmalicious or typo'd registry source cannot turn the daemon into a\nrequest-forgery vector against internal services.\n\nThis opt-out is BLANKET (all-or-nothing): setting it true disables the\nguard for EVERY non-routable range at once — loopback, RFC1918/CGNAT\nprivate, link-local AND the 169.254.169.254 cloud-metadata endpoint. There\nis no way to allow only loopback; enabling it for a localhost dev registry\nalso re-opens the cloud-metadata SSRF vector. Set true ONLY when you\nintentionally run a trusted registry mirror on an internal/private address,\nideally on a host with no cloud-metadata exposure. The change takes effect\nonly on daemon (re)start or config reload.","type":"boolean"},"allow_server_add":{"type":"boolean"},"allow_server_remove":{"type":"boolean"},"api_key":{"description":"Security settings","type":"string"},"call_tool_timeout":{"type":"string"},"check_server_repo":{"description":"Repository detection settings","type":"boolean"},"code_execution_max_tool_calls":{"description":"Max tool calls per execution (0 = unlimited, default: 0)","type":"integer"},"code_execution_pool_size":{"description":"JavaScript runtime pool size (default: 10)","type":"integer"},"code_execution_timeout_ms":{"description":"Timeout in milliseconds (default: 120000, max: 600000)","type":"integer"},"data_dir":{"type":"string"},"debug_search":{"type":"boolean"},"disable_management":{"type":"boolean"},"docker_isolation":{"$ref":"#/components/schemas/config.DockerIsolationConfig"},"docker_recovery":{"$ref":"#/components/schemas/config.DockerRecoveryConfig"},"enable_code_execution":{"description":"Code execution settings","type":"boolean"},"enable_prompts":{"description":"Prompts settings","type":"boolean"},"enable_socket":{"description":"Enable Unix socket/named pipe for local IPC (default: true)","type":"boolean"},"enable_tray":{"description":"Deprecated: EnableTray is unused and has no runtime effect. Kept for backward compatibility.","type":"boolean"},"environment":{"$ref":"#/components/schemas/secureenv.EnvConfig"},"features":{"$ref":"#/components/schemas/config.FeatureFlags"},"forward_proxy_env":{"description":"ForwardProxyEnv opts in to forwarding the ambient HTTP(S)/ALL/NO/FTP proxy\nenvironment variables to spawned stdio upstream servers (MCP-2769). OFF by\ndefault: proxy URLs commonly embed credentials (http://user:pass@proxy), so\nforwarding them to every upstream is a credential-leak risk. When enabled,\nvalues are forwarded with their userinfo (credentials) redacted.","type":"boolean"},"health_check_interval":{"description":"Discovery \u0026 health-check cadence (spec 074, #608). Both are *Duration\ntri-state pointers: nil = inherit the built-in default; a pointer to 0s =\nthe loop is disabled; a positive value = that interval. Defaults live only\nin the resolvers (ResolveHealthCheckInterval / ResolveToolDiscoveryInterval)\nso an unset key behaves exactly as before this feature (SC-005). Validated\nin Validate(): health-check ∈ {0} ∪ [5s,1h]; tool-discovery ∈ {0} ∪ [30s,24h].","type":"string"},"init_timeout":{"description":"InitTimeout is the global default deadline for an upstream's MCP\n` + "`" + `initialize` + "`" + ` handshake (MCP-3322 / GH #760). *Duration tri-state: nil =\ninherit the built-in 30s default; a positive value = that deadline. A\nper-server InitTimeout overrides this. Resolved by ResolveInitTimeout;\nvalidated to {0} ∪ [1s, 30m] in Validate(). Servers doing legitimate\nfirst-run warmup (cache/index build) before answering ` + "`" + `initialize` + "`" + ` can\nraise this so they are not killed mid-startup.","type":"string"},"instructions":{"description":"Instructions text returned in the MCP initialize response to guide AI agents.\nWhen empty, a built-in default is used that explains retrieve_tools workflow.","type":"string"},"intent_declaration":{"$ref":"#/components/schemas/config.IntentDeclarationConfig"},"listen":{"type":"string"},"logging":{"$ref":"#/components/schemas/config.LogConfig"},"max_concurrent_requests":{"description":"Concurrency limits (spec 093, GH #955). Scope (a) of FR-020: the GLOBAL\nAGGREGATE limiter — one proxy-wide cap on concurrently running upstream\ntool calls, with its own bounded wait queue. Tri-state pointers: absent =\nthe limiter does not exist (default, zero behavior change); an explicit 0\nmax also disables it; positive = that cap. This scope is NEVER a\nper-server inheritance source — per-server values come from\nServerConcurrencyDefaults / the per-server overrides — but a server's\neffective concurrency is bounded by BOTH its own limiter and this one.\nResolved by ResolveGlobalConcurrency; hot-reloadable; overridable via\nMCPPROXY_MAX_CONCURRENT_REQUESTS / _QUEUE_SIZE / _QUEUE_TIMEOUT (FR-022).","type":"integer"},"max_result_size_chars":{"description":"Advertised on every tool as ` + "`" + `_meta.anthropic/maxResultSizeChars` + "`" + `; raises Claude Code's inline-response ceiling from 50k to up to 500k chars. Set to 0 to disable.","type":"integer"},"mcpServers":{"items":{"$ref":"#/components/schemas/config.ServerConfig"},"type":"array","uniqueItems":false},"oauth_expiry_warning_hours":{"description":"Health status settings","type":"number"},"observability":{"$ref":"#/components/schemas/config.ObservabilityConfig"},"output_sanitisation":{"$ref":"#/components/schemas/config.OutputSanitisationConfig"},"output_validation":{"$ref":"#/components/schemas/config.OutputValidationConfig"},"profiles":{"description":"Profiles are optional named, server-scoped views exposed at /mcp/p/\u003cname\u003e\n(Spec 057). Absent/empty is fully supported — /mcp is unchanged and configs\nwithout this key serialize byte-identically (SC-004).","items":{"$ref":"#/components/schemas/config.ProfileConfig"},"type":"array","uniqueItems":false},"quarantine_enabled":{"description":"QuarantineEnabled controls whether quarantine is active. It gates two\nthings together:\n 1. Server-level auto-quarantine for newly added servers (issue #370).\n When true, servers added via the upstream_servers MCP tool or the\n REST API default to quarantined=true; when false, they default to\n quarantined=false. Explicit per-request values always win.\n 2. Tool-level quarantine (Spec 032): per-tool SHA-256 approval of\n tool descriptions/schemas.\nWhen nil (default), quarantine is enabled (secure by default). Set to\nexplicit false to opt out of both. Per-server SkipQuarantine still\napplies for the tool-level check on individual servers.","type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"read_only_mode":{"type":"boolean"},"registries":{"description":"Registries configuration for MCP server discovery","items":{"$ref":"#/components/schemas/config.RegistryEntry"},"type":"array","uniqueItems":false},"registries_locked":{"description":"RegistriesLocked is an enterprise stub knob (MCP-866): when true, runtime\nadditions of custom registries (e.g. ` + "`" + `registry add-source` + "`" + `, the REST/MCP\nadd-source surface) are rejected so an administrator can pin the discovery\nsources. Built-in defaults are unaffected. Documented but otherwise inert\nbeyond the add-source rejection.","type":"boolean"},"require_mcp_auth":{"description":"Require authentication on /mcp endpoint (default: false)","type":"boolean"},"reveal_secret_headers":{"description":"RevealSecretHeaders, when true, disables the redaction of the\nsecret-bearing server fields — sensitive header values (Authorization,\nX-API-Key, Cookie, …), env-var secrets, and URL query credentials — in\nresponses from the ` + "`" + `upstream_servers` + "`" + ` MCP tool, the ` + "`" + `/api/v1/servers` + "`" + `\nREST API, and the SSE event stream. It also lets URL secrets echoed\ninto last_error / health.detail through unscrubbed.\n\nDefault false — sensitive values are surfaced masked as\n` + "`" + `••••\u003clast2\u003e (\u003cN\u003e chars)` + "`" + ` (error strings use ` + "`" + `***REDACTED***` + "`" + `) so an\nMCP agent cannot read Bearer tokens / API keys / URL secrets out of\nanother upstream's config (PR #425, issue #872). ${env:…}/${keyring:…}\nreferences are labels, not secrets, and pass through unchanged.\n\nThe Web UI / macOS tray edit forms work without seeing the real\nvalues: PATCH /api/v1/servers/{id} deep-merges (omitted keys are\npreserved, see ` + "`" + `headers_remove` + "`" + ` / ` + "`" + `env_remove` + "`" + ` for explicit\ndeletes), so clients compute a diff and only send the keys that\nactually changed. Redacted-but-unchanged values never round-trip\n— the backend keeps the real string. Set this to true if a\ndownstream tool genuinely needs raw values in the response.","type":"boolean"},"routing_mode":{"description":"Routing mode (Spec 031): how MCP tools are exposed to clients\nValid values: \"retrieve_tools\" (default), \"direct\", \"code_execution\"","type":"string"},"security":{"$ref":"#/components/schemas/config.SecurityConfig"},"sensitive_data_detection":{"$ref":"#/components/schemas/config.SensitiveDataDetectionConfig"},"server_concurrency_defaults":{"$ref":"#/components/schemas/config.ConcurrencyDefaults"},"telemetry":{"$ref":"#/components/schemas/config.TelemetryConfig"},"tls":{"$ref":"#/components/schemas/config.TLSConfig"},"tokenizer":{"$ref":"#/components/schemas/config.TokenizerConfig"},"tool_discovery_interval":{"type":"string"},"tool_response_limit":{"type":"integer"},"tool_response_mode":{"description":"Tool response mode (Spec 085): how retrieve_tools serializes results.\nValid values: \"\" (= full), \"full\" (default: today's schema-bearing\nentries), \"compact\" (signature + first-sentence entries). Orthogonal to\nrouting_mode — routing_mode selects the tool SURFACE, this selects the\nSERIALIZATION within the retrieve_tools surface. Serialization-only: it\nnever affects the query, ranking, or result set. Hot-reloadable.","type":"string"},"tool_response_session_risk_warning":{"description":"ToolResponseSessionRiskWarning controls whether the prose ` + "`" + `warning` + "`" + ` field\nis included in the ` + "`" + `session_risk` + "`" + ` object returned by ` + "`" + `retrieve_tools` + "`" + `.\nThe structured fields (level, lethal_trifecta, has_open_world_tools, etc.)\nare always included. Default: false (quiet for LLM clients) — see issue #406.\nMost tools lack annotations, so the MCP-spec defaults treat them as fully\npermissive across all three risk axes, which makes the prose warning fire\non almost every call and wastes tokens.","type":"boolean"},"tools_limit":{"type":"integer"},"toon_min_savings_pct":{"description":"ToonMinSavingsPct is the minimum byte-savings percentage (validated\n1-90; 0/unset → 15) the complete TOON emission (marker + hint + body)\nmust achieve over the exact passthrough emission for adaptive mode to\nencode a block. Byte savings approximate token savings for the tabular\npayload class; the spec-083 profiler reports true token deltas.\nGlobal-only (no per-server override, FR-001).","type":"integer"},"toon_output":{"description":"ToonOutput selects the TOON encoding mode for call_tool_* result text\nblocks (spec 084): \"off\" (default — responses byte-identical to\npre-feature behavior), \"adaptive\" (encode only tabular-uniform payloads\nthat beat compact JSON by ToonMinSavingsPct), or \"always\"\n(benchmark/debug only — encodes every JSON-parseable block and can\nINCREASE token cost). Per-server override: ServerConfig.ToonOutput.\nResolved by ResolveToonOutput; hot-reloadable.","type":"string"},"top_k":{"description":"Deprecated: TopK is superseded by ToolsLimit and has no runtime effect. Kept for backward compatibility.","type":"integer"},"tray_endpoint":{"description":"Tray endpoint override (unix:// or npipe://)","type":"string"},"trusted_hosts":{"description":"TrustedHosts lists non-loopback Host header values accepted on loopback\nlisteners (GH #898). DNS-rebinding protection rejects requests whose Host\nheader is not a loopback address when mcpproxy listens on loopback; a\nreverse proxy (nginx → 127.0.0.1) forwarding the public domain in Host\ntrips it. Entries are hostnames, case-insensitive; an entry without a\nport matches any port, with a port it must match exactly; a leading dot\n(\".example.com\") is a subdomain wildcard. The single entry \"*\" disables\nHost and Origin validation entirely. The same list also validates the\nOrigin header when present (MCP spec DNS-rebinding defense). Empty\n(default) keeps full protection. Env override: MCPPROXY_TRUSTED_HOSTS\n(comma-separated).","items":{"type":"string"},"type":"array","uniqueItems":false},"update_check":{"$ref":"#/components/schemas/config.UpdateCheckConfig"}},"type":"object"},"config.CustomPattern":{"properties":{"category":{"description":"Category (defaults to \"custom\")","type":"string"},"keywords":{"description":"Keywords to match (mutually exclusive with Regex)","items":{"type":"string"},"type":"array","uniqueItems":false},"name":{"description":"Unique identifier for this pattern","type":"string"},"regex":{"description":"Regex pattern (mutually exclusive with Keywords)","type":"string"},"severity":{"description":"Risk level: critical, high, medium, low","type":"string"}},"type":"object"},"config.DeepScanConfig":{"description":"DeepScan is the opt-in \"deep scan\" layer (Spec 077 US3). It subsumes the\ndeprecated top-level scanner_fetch_package_source / scanner_disable_no_new_privileges\nkeys (migrated on load) and gates the heavy Docker-based scanners + source\nextraction. Disabled by default (FR-006): only the deterministic in-process\nbaseline scanner runs. A deep-scan failure NEVER changes the baseline verdict\n(FR-007/FR-008).","properties":{"disable_no_new_privileges":{"description":"DisableNoNewPrivileges, when true, omits the ` + "`" + `--security-opt\nno-new-privileges` + "`" + ` flag from scanner container runs (snap-docker/AppArmor\nescape hatch). Absorbs the deprecated top-level\nscanner_disable_no_new_privileges. Default false.","type":"boolean"},"enabled":{"description":"Enabled is the master opt-in for the heavy layer (FR-006). Default false.","type":"boolean"},"fetch_package_source":{"description":"FetchPackageSource controls whether the scanner fetches the PUBLISHED\nsource of package-runner servers (npx/uvx) — without executing it — when\nno local source is available. Absorbs the deprecated top-level\nscanner_fetch_package_source. Default (nil) is ENABLED within deep scan.","type":"boolean"},"scanners":{"description":"Scanners optionally restricts which deep scanners may run under the\numbrella (by scanner id). Empty ⇒ all enabled deep scanners are eligible.","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.DockerIsolationConfig":{"description":"Docker isolation settings","properties":{"cpu_limit":{"description":"CPU limit for containers","type":"string"},"default_images":{"additionalProperties":{"type":"string"},"description":"Map of runtime type to Docker image","type":"object"},"enable_cache_volume":{"description":"Mount shared cache volumes for faster restarts (default: true)","type":"boolean"},"enabled":{"description":"Global enable/disable for Docker isolation (legacy; superseded by Mode)","type":"boolean"},"extra_args":{"description":"Additional docker run arguments","items":{"type":"string"},"type":"array","uniqueItems":false},"log_driver":{"description":"Docker log driver (default: json-file)","type":"string"},"log_max_files":{"description":"Maximum number of log files (default: 3)","type":"string"},"log_max_size":{"description":"Maximum size of log files (default: 100m)","type":"string"},"memory_limit":{"description":"Memory limit for containers","type":"string"},"mode":{"description":"Isolation mode: \"docker\" | \"sandbox\" | \"none\" (MCP-34.2). Unset per-server inherits the global mode; unset globally falls back to the legacy \"enabled\" flag (true ⇒ docker, false ⇒ none)","type":"string","x-enum-varnames":["IsolationModeDocker","IsolationModeSandbox","IsolationModeNone"]},"network_mode":{"description":"Docker network mode (default: bridge)","type":"string"},"registry":{"description":"Custom registry (defaults to docker.io)","type":"string"},"timeout":{"description":"Container startup timeout","type":"string"}},"type":"object"},"config.DockerRecoveryConfig":{"description":"Docker recovery settings","properties":{"enabled":{"description":"Enable Docker recovery monitoring (default: true)","type":"boolean"},"max_retries":{"description":"Maximum retry attempts (0 = unlimited)","type":"integer"},"notify_on_failure":{"description":"Show notification on recovery failure (default: true)","type":"boolean"},"notify_on_retry":{"description":"Show notification on each retry (default: false)","type":"boolean"},"notify_on_start":{"description":"Show notification when recovery starts (default: true)","type":"boolean"},"notify_on_success":{"description":"Show notification on successful recovery (default: true)","type":"boolean"},"persistent_state":{"description":"Save recovery state across restarts (default: true)","type":"boolean"}},"type":"object"},"config.FeatureFlags":{"description":"Deprecated: Features flags are unused and have no runtime effect. Kept for backward compatibility.","properties":{"enable_async_storage":{"type":"boolean"},"enable_caching":{"type":"boolean"},"enable_contract_tests":{"type":"boolean"},"enable_debug_logging":{"description":"Development features","type":"boolean"},"enable_docker_isolation":{"type":"boolean"},"enable_event_bus":{"type":"boolean"},"enable_health_checks":{"type":"boolean"},"enable_metrics":{"type":"boolean"},"enable_oauth":{"description":"Security features","type":"boolean"},"enable_observability":{"description":"Observability features","type":"boolean"},"enable_quarantine":{"type":"boolean"},"enable_runtime":{"description":"Runtime features","type":"boolean"},"enable_search":{"description":"Storage features","type":"boolean"},"enable_sse":{"type":"boolean"},"enable_tracing":{"type":"boolean"},"enable_tray":{"type":"boolean"},"enable_web_ui":{"description":"UI features","type":"boolean"}},"type":"object"},"config.IntentDeclarationConfig":{"description":"Intent declaration settings (Spec 018)","properties":{"strict_server_validation":{"description":"StrictServerValidation controls whether server annotation mismatches\ncause rejection (true) or just warnings (false).\nDefault: true (reject mismatches)","type":"boolean"}},"type":"object"},"config.IsolationConfig":{"description":"Per-server isolation settings","properties":{"enabled":{"description":"Enable Docker isolation for this server (nil = inherit global; legacy, superseded by Mode)","type":"boolean"},"extra_args":{"description":"Additional docker run arguments for this server","items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"description":"Custom Docker image (overrides default)","type":"string"},"log_driver":{"description":"Docker log driver override for this server","type":"string"},"log_max_files":{"description":"Maximum number of log files override","type":"string"},"log_max_size":{"description":"Maximum size of log files override","type":"string"},"mode":{"$ref":"#/components/schemas/config.IsolationMode"},"network_mode":{"description":"Custom network mode for this server","type":"string"},"working_dir":{"description":"Custom working directory in container","type":"string"}},"type":"object"},"config.IsolationMode":{"description":"Isolation mode: \"docker\" | \"sandbox\" | \"none\" (MCP-34.2). Unset per-server inherits the global mode; unset globally falls back to the legacy \"enabled\" flag (true ⇒ docker, false ⇒ none)","type":"string","x-enum-varnames":["IsolationModeDocker","IsolationModeSandbox","IsolationModeNone"]},"config.LogConfig":{"description":"Logging configuration","properties":{"compress":{"type":"boolean"},"enable_console":{"type":"boolean"},"enable_file":{"type":"boolean"},"filename":{"type":"string"},"json_format":{"type":"boolean"},"level":{"type":"string"},"log_dir":{"description":"Custom log directory","type":"string"},"max_age":{"description":"days","type":"integer"},"max_backups":{"description":"number of backup files","type":"integer"},"max_size":{"description":"MB","type":"integer"}},"type":"object"},"config.MetricsExporterConfig":{"description":"Metrics gates the Prometheus /metrics scrape endpoint (MCP-32). Disabled\nby default — operators opt in for k8s/enterprise deployments.","properties":{"enabled":{"description":"Enabled exposes /metrics on the existing HTTP listener when true.","type":"boolean"}},"type":"object"},"config.OAuthConfig":{"description":"OAuth configuration (keep even when empty to signal OAuth requirement)","properties":{"client_id":{"type":"string"},"client_secret":{"type":"string"},"extra_params":{"additionalProperties":{"type":"string"},"description":"Additional OAuth parameters (e.g., RFC 8707 resource)","type":"object"},"pkce_enabled":{"type":"boolean"},"redirect_uri":{"type":"string"},"scopes":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.ObservabilityConfig":{"description":"Observability settings (Spec 069): usage aggregate cache/persistence cadence.","properties":{"metrics":{"$ref":"#/components/schemas/config.MetricsExporterConfig"},"tracing":{"$ref":"#/components/schemas/config.TracingExporterConfig"},"usage_cache_ttl":{"description":"UsageCacheTTL bounds the freshness of the usage endpoint's read cache for\nwide windows (FR-005). Default 5s.","type":"string"},"usage_persist_interval":{"description":"UsagePersistInterval is how often the actor-owned usage aggregate snapshot\nis flushed to storage. Default 30s.","type":"string"}},"type":"object"},"config.OutputSanitisationConfig":{"description":"Output sanitisation settings (Spec 054 Track B)","properties":{"max_redactions":{"description":"cap on redactions per response; default 100","type":"integer"},"response_action":{"description":"\"spotlight\" | \"redact\" | \"block\"; default \"spotlight\"","type":"string"},"spotlight_untrusted":{"description":"wrap untrusted output in spotlight markers; default true","type":"boolean"},"strip_classes":{"description":"classes to strip: ansi/c0c1/bidi/zero_width","items":{"type":"string"},"type":"array","uniqueItems":false},"strip_control_chars":{"description":"strip control-character classes; default false","type":"boolean"}},"type":"object"},"config.OutputValidationConfig":{"description":"Output-schema validation settings (Spec 056)","properties":{"max_bytes":{"description":"structured payload byte cap; default 5\u003c\u003c20","type":"integer"},"max_depth":{"description":"nesting depth cap; default 64","type":"integer"},"missing_structured_content":{"description":"\"allow\" | \"block\"; default \"allow\"","type":"string"},"mode":{"description":"\"off\" | \"warn\" | \"strict\"; default \"warn\"","type":"string"}},"type":"object"},"config.ProfileConfig":{"properties":{"name":{"description":"URL slug, validated","type":"string"},"servers":{"description":"references to mcpServers[].name","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.RegistryEntry":{"properties":{"count":{"description":"number or string","type":"string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"protocol":{"type":"string"},"provenance":{"description":"Provenance is the trust tag for this registry (MCP-866):\nRegistryProvenanceOfficial for built-in defaults, RegistryProvenanceCustom\nfor user-added registries. It is authoritatively (re)computed by the\nregistries merge from whether the ID is a shipped default — a user cannot\nclaim \"official\" by writing it into their config.","type":"string"},"requires_key":{"description":"RequiresKey marks a registry that needs an API key to be queried. When\ntrue and no key is configured, the registry is skipped/marked unavailable\nrather than failing the whole search (FR-008).","type":"boolean"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"url":{"type":"string"}},"type":"object"},"config.SecurityConfig":{"description":"Security scanner settings (Spec 039)","properties":{"deep_scan":{"$ref":"#/components/schemas/config.DeepScanConfig"},"integrity_check_interval":{"type":"string"},"integrity_check_on_restart":{"type":"boolean"},"runtime_read_only":{"type":"boolean"},"runtime_tmpfs_size":{"type":"string"},"scan_timeout_default":{"type":"string"},"scanner_disable_no_new_privileges":{"description":"Deprecated (Spec 077 US3): migrated on load into DeepScan.DisableNoNewPrivileges\n(see migrateDeepScanConfig). Retained only so existing configs that still carry\nthe top-level key parse; consumers MUST read the effective value via\nSecurityConfig.IsDisableNoNewPrivileges. Cleared after migration.\n\nScannerDisableNoNewPrivileges, when true, omits the\n` + "`" + `--security-opt no-new-privileges` + "`" + ` flag from scanner container runs.\n\nBackground: snap-installed Docker on Ubuntu confines dockerd under the\n` + "`" + `snap.docker.dockerd` + "`" + ` AppArmor profile. When runc tries to transition\nthe container into the inner ` + "`" + `docker-default` + "`" + ` profile to exec the\nentrypoint, AppArmor refuses the transition because NO_NEW_PRIVS\nforbids privilege/profile changes on exec — the result is EPERM\n(\"operation not permitted\") and every scanner fails immediately.\n\nSet this to true ONLY on hosts hitting that incompatibility. Scanner\ncontainers still run with read-only rootfs, tmpfs /tmp, no-network by\ndefault, and read-only source mounts, so the marginal isolation loss\nis small. The preferred fix remains replacing snap docker with a\ndistro-packaged docker.","type":"boolean"},"scanner_fetch_package_source":{"description":"Deprecated (Spec 077 US3): migrated on load into DeepScan.FetchPackageSource\n(see migrateDeepScanConfig). Retained only so existing configs that still carry\nthe top-level key parse; consumers MUST read the effective value via\nSecurityConfig.EffectiveFetchPackageSource. Cleared after migration.\n\nScannerFetchPackageSource controls whether the scanner fetches the\nPUBLISHED source of package-runner servers (npx/uvx) — without executing\nit — when no local source is available (no Docker container, no local\npackage cache, no working_dir). This is the primary quarantine/scan\ntarget: a quarantined-on-add server is never run locally, so without this\nthe scan degrades to tool-definitions-only (no real source-level\nanalysis). See MCP-2206.\n\nFetching uses ` + "`" + `npm pack --ignore-scripts` + "`" + ` (npm) and ` + "`" + `uv pip download` + "`" + ` /\n` + "`" + `pip download` + "`" + ` with ` + "`" + `--only-binary=:all:` + "`" + ` (Python), which only download +\nunpack archives and NEVER run install, build, or setup.py — a scanner must\nnot execute the untrusted code it is scanning. The Python\n` + "`" + `--only-binary=:all:` + "`" + ` flag is required because downloading an sdist would\ninvoke its build backend (setup.py); packages with no wheel fall back to\ntool-definitions-only instead. Extraction is hardened against path\ntraversal and decompression bombs.\n\nDefault (nil) is ENABLED. Set to false on air-gapped deployments to\nforbid the scanner's network egress; such servers then fall back to the\ntool-definitions-only scan with no regression.","type":"boolean"},"scanner_registry_url":{"type":"string"},"tpa_bundle_path":{"description":"TPABundlePath is the filesystem path to the tpa-db scanner-bundle.json\nthe offline TPA scanner runs (spec 086 FR-019: the signature-DB location\nMUST be configuration-driven, not hardcoded). Empty (the default) runs the\ncorpus embedded in this build.\n\nEnv override: MCPPROXY_TPA_BUNDLE_PATH. Hot-reloadable — the path is\nre-read on every config.reloaded event via\nscanner.Service.ApplySecurityConfig, so a corpus refresh needs no restart.\nA configured bundle that fails to read/parse/version-check/compile is\nREFUSED and the previously active corpus stays live (fail-closed, never\nfail-empty); the reason is logged and surfaced in the security overview's\nsignature_bundle.load_error.","type":"string"}},"type":"object"},"config.SensitiveDataDetectionConfig":{"description":"Sensitive data detection settings (Spec 026)","properties":{"categories":{"additionalProperties":{"type":"boolean"},"description":"Enable/disable specific detection categories","type":"object"},"custom_patterns":{"description":"User-defined detection patterns","items":{"$ref":"#/components/schemas/config.CustomPattern"},"type":"array","uniqueItems":false},"enabled":{"description":"Enable sensitive data detection (default: true)","type":"boolean"},"entropy_threshold":{"description":"Shannon entropy threshold for high-entropy detection (default: 4.5)","type":"number"},"max_payload_size_kb":{"description":"Max size to scan before truncating (default: 1024)","type":"integer"},"scan_requests":{"description":"Scan tool call arguments (default: true)","type":"boolean"},"scan_responses":{"description":"Scan tool responses (default: true)","type":"boolean"},"sensitive_keywords":{"description":"Keywords to flag","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.ServerConfig":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges is the per-server intent to auto-approve tool\nchanges/additions (disabling per-server rug-pull protection). Supersedes\nskip_quarantine. MCP-2930 only ACCEPTS, persists, and migrates this flag — it\nis NOT yet consulted at runtime; auto-approval is still governed by\nSkipQuarantine until the trust-baseline behavior change (MCP-2931) migrates the\nruntime consumers onto it.\nTri-state pointer (mirrors QuarantineEnabled): nil = unset (inherit/migrate\nfrom legacy skip_quarantine), explicit true/false = honored as-is so an\nexplicit auto_approve_tool_changes:false overrides a legacy skip_quarantine:true.\nRead via IsAutoApproveToolChanges().","type":"boolean"},"command":{"type":"string"},"created":{"type":"string"},"disabled_tools":{"description":"Denylist: these tools are hidden; mutually exclusive with enabled_tools","items":{"type":"string"},"type":"array","uniqueItems":false},"enabled":{"type":"boolean"},"enabled_tools":{"description":"Allowlist: only these tools are exposed; mutually exclusive with disabled_tools","items":{"type":"string"},"type":"array","uniqueItems":false},"env":{"additionalProperties":{"type":"string"},"type":"object"},"headers":{"additionalProperties":{"type":"string"},"description":"For HTTP servers","type":"object"},"health_check_interval":{"description":"Per-server discovery \u0026 health-check overrides (spec 074). Same *Duration\ntri-state as the global keys: nil = inherit the global value (or default),\npointer to 0s = disabled for this server, positive = that interval.\nHealthCheckInterval is fully wired into the per-server health loop;\nToolDiscoveryInterval is accepted/validated and round-trips for\nforward-compat, but the periodic index sweep is governed by the global\ncadence in this iteration (see spec 074 plan §C).","type":"string"},"init_timeout":{"description":"InitTimeout overrides the global init_timeout for this server's MCP\n` + "`" + `initialize` + "`" + ` handshake deadline (MCP-3322 / GH #760). *Duration tri-state:\nnil = inherit the global value (or 30s default), positive = that deadline.\nResolved by Config.ResolveInitTimeout; validated to {0} ∪ [1s, 30m]. Raise\nthis for upstreams that do legitimate first-run warmup (e.g. caching many\nchannels/users) before responding to ` + "`" + `initialize` + "`" + `.","type":"string"},"isolation":{"$ref":"#/components/schemas/config.IsolationConfig"},"launcher_wait_timeout":{"description":"LauncherWaitTimeout caps how long mcpproxy will wait for a locally-launched\nHTTP/SSE upstream's URL to become reachable after Spawn(). Only consulted\nwhen the server is configured with both Command and an HTTP/SSE URL — i.e.,\nmcpproxy starts the process AND connects via network. Stdio servers ignore\nthis field. Zero or unset → 30s default.","type":"string"},"max_concurrent_requests":{"description":"Per-server concurrency overrides — scope (c) of FR-020 (spec 093, #955).\nTri-state per setting, exactly like HealthCheckInterval: absent = inherit\nthe per-server default set (server_concurrency_defaults), explicit 0 =\ndisable that setting for this server (0 max = no per-server limiter at\nall; 0 queue_size = no pending capacity, shed immediately at the cap),\npositive = override. The global aggregate limiter is never inherited from\nhere — it applies on top, so effective concurrency is min(per-server,\nglobal). Resolved by Config.ResolveServerConcurrency.","type":"integer"},"name":{"type":"string"},"oauth":{"$ref":"#/components/schemas/config.OAuthConfig"},"protocol":{"description":"stdio, http, sse, streamable-http, auto","type":"string"},"quarantined":{"description":"Security quarantine status","type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"reconnect_on_use":{"description":"Attempt reconnection when a tool call targets a disconnected server","type":"boolean"},"shared":{"description":"Server edition: shared with all users","type":"boolean"},"skip_quarantine":{"description":"SkipQuarantine is DEPRECATED (MCP-2930): use AutoApproveToolChanges instead.\nKept for back-compat parsing; on config load a legacy skip_quarantine:true is\nmigrated to auto_approve_tool_changes:true only when the new field is unset\n(see normalizeServerQuarantineFlags).","type":"boolean"},"source_registry_id":{"description":"SourceRegistryID records which registry this server was added from (empty\nfor manually-configured servers). MCP-866: surfaced in the approval /\nquarantine view so a reviewer can see a server's origin.","type":"string"},"source_registry_provenance":{"description":"SourceRegistryProvenance records the source registry's provenance at add\ntime (RegistryProvenanceOfficial / RegistryProvenanceCustom). It is purely\ninformational (MCP-1072) — surfaced so a reviewer can see a server's origin\n— and no longer gates quarantine or skip_quarantine.","type":"string"},"tool_discovery_interval":{"type":"string"},"toon_output":{"description":"ToonOutput overrides the global toon_output mode for this server's\ntools (spec 084, FR-001). Plain string, not a pointer: \"\"/absent =\ninherit the global value; \"off\"|\"adaptive\"|\"always\" = override (\"off\"\nis the explicit force-off). Resolved by Config.ResolveToonOutput.","type":"string"},"trust_mode":{"description":"TrustMode is the per-server trust tier: auto|scan|manual. Supersedes\nauto_approve_tool_changes (spec 086). An empty value is derived from the\nlegacy fields at load via normalizeServerQuarantineFlags; the single\nresolution point is EffectiveTrustMode(), which treats an empty or\nunrecognized value as manual (secure by default). Read via\nEffectiveTrustMode(), never the raw string.","type":"string"},"updated":{"type":"string"},"url":{"type":"string"},"working_dir":{"description":"Working directory for stdio servers","type":"string"}},"type":"object"},"config.TLSConfig":{"description":"TLS configuration","properties":{"certs_dir":{"description":"Directory for certificates","type":"string"},"enabled":{"description":"Enable HTTPS","type":"boolean"},"hsts":{"description":"Enable HTTP Strict Transport Security","type":"boolean"},"require_client_cert":{"description":"Enable mTLS","type":"boolean"}},"type":"object"},"config.TelemetryConfig":{"description":"Telemetry settings (Spec 036)","properties":{"anonymous_id":{"description":"Auto-generated UUIDv4","type":"string"},"anonymous_id_created_at":{"description":"Spec 042 (Tier 2) additions — all default-zero, all backwards-compatible.","type":"string"},"enabled":{"description":"Default: true (opt-out)","type":"boolean"},"endpoint":{"description":"Override for testing","type":"string"},"last_reported_version":{"description":"Upgrade funnel","type":"string"},"last_startup_outcome":{"description":"success|port_conflict|db_locked|...","type":"string"},"notice_shown":{"description":"First-run notice flag","type":"boolean"}},"type":"object"},"config.TokenizerConfig":{"description":"Tokenizer configuration for token counting","properties":{"default_model":{"description":"Default model for tokenization (e.g., \"gpt-4\")","type":"string"},"enabled":{"description":"Enable token counting","type":"boolean"},"encoding":{"description":"Default encoding (e.g., \"cl100k_base\")","type":"string"}},"type":"object"},"config.TracingExporterConfig":{"description":"Tracing gates the OpenTelemetry OTLP trace exporter (MCP-32). Disabled by\ndefault.","properties":{"enabled":{"description":"Enabled turns on OTLP trace export for tool calls and upstream hops.","type":"boolean"},"endpoint":{"description":"Endpoint is the collector address as host:port (no scheme), e.g.\n\"localhost:4318\" for http or \"localhost:4317\" for grpc.","type":"string"},"protocol":{"description":"Protocol selects the OTLP transport: \"http\" or \"grpc\".","type":"string"},"sample_rate":{"description":"SampleRate is the head-based trace sampling ratio in [0,1]. Default 0.1.","type":"number"}},"type":"object"},"config.UpdateCheckConfig":{"description":"Update-check settings (Spec 079 FR-012): config-file control of the\nbackground upgrade-awareness checker (internal/updatecheck). nil =\nenabled on the stable channel (existing default behavior). The existing\nenvironment switches keep working and WIN over these keys (FR-014):\nMCPPROXY_DISABLE_AUTO_UPDATE=true force-disables even when\nenabled=true, and MCPPROXY_ALLOW_PRERELEASE_UPDATES=true force-selects\nthe rc channel even when channel=stable.","properties":{"channel":{"description":"Channel selects which releases are offered as updates: \"stable\"\n(default; prereleases never offered) or \"rc\" (prereleases included).\nEmpty resolves to stable. Validated in ValidateDetailed.","type":"string"},"enabled":{"description":"Enabled gates all update checking. Tri-state: nil/absent = enabled\n(default true, matching pre-079 behavior). When false, no network\ncheck is performed and no upgrade nudge appears on any surface\n(FR-015) — /api/v1/info omits the update object entirely.","type":"boolean"}},"type":"object"},"configimport.FailedServer":{"properties":{"details":{"type":"string"},"error":{"type":"string"},"name":{"type":"string"}},"type":"object"},"configimport.ImportSummary":{"properties":{"failed":{"type":"integer"},"imported":{"type":"integer"},"skipped":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"configimport.SkippedServer":{"properties":{"name":{"type":"string"},"reason":{"description":"\"already_exists\", \"filtered_out\", \"invalid_name\"","type":"string"}},"type":"object"},"connect.ConnectResult":{"description":"The full result; its action mirrors the top-level one","properties":{"action":{"description":"\"created\", \"updated\", \"already_exists\", \"removed\", \"not_found\"","type":"string"},"backup_path":{"type":"string"},"client":{"type":"string"},"config_path":{"type":"string"},"message":{"type":"string"},"server_name":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.APIResponse":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.ActivityDetailResponse":{"properties":{"activity":{"$ref":"#/components/schemas/contracts.ActivityRecord"}},"type":"object"},"contracts.ActivityListResponse":{"properties":{"activities":{"items":{"$ref":"#/components/schemas/contracts.ActivityRecord"},"type":"array","uniqueItems":false},"limit":{"type":"integer"},"offset":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"contracts.ActivityRecord":{"properties":{"arguments":{"description":"Tool call arguments","type":"object"},"detection_types":{"description":"List of detection types found","items":{"type":"string"},"type":"array","uniqueItems":false},"duration_ms":{"description":"Execution duration in milliseconds","type":"integer"},"error_message":{"description":"Error details if status is \"error\"","type":"string"},"has_sensitive_data":{"description":"Sensitive data detection fields (Spec 026)","type":"boolean"},"id":{"description":"Unique identifier (ULID format)","type":"string"},"max_severity":{"description":"Highest severity level detected (critical, high, medium, low)","type":"string"},"metadata":{"description":"Additional context-specific data","type":"object"},"request_id":{"description":"HTTP request ID for correlation","type":"string"},"response":{"description":"Tool response (potentially truncated)","type":"string"},"response_truncated":{"description":"True if response was truncated","type":"boolean"},"server_name":{"description":"Name of upstream MCP server","type":"string"},"session_id":{"description":"MCP transport session ID (regenerated on every reconnect)","type":"string"},"source":{"$ref":"#/components/schemas/contracts.ActivitySource"},"status":{"description":"Result status: \"success\", \"error\", \"blocked\", \"rejected\"","type":"string"},"timestamp":{"description":"When activity occurred","type":"string"},"tool_name":{"description":"Name of tool called","type":"string"},"type":{"$ref":"#/components/schemas/contracts.ActivityType"},"work_session_id":{"description":"Spec 082: one client, one project, across reconnects","type":"string"}},"type":"object"},"contracts.ActivitySource":{"description":"How activity was triggered: \"mcp\", \"cli\", \"api\"","type":"string","x-enum-varnames":["ActivitySourceMCP","ActivitySourceCLI","ActivitySourceAPI"]},"contracts.ActivitySummaryResponse":{"properties":{"blocked_count":{"description":"Count of blocked activities","type":"integer"},"end_time":{"description":"End of the period (RFC3339)","type":"string"},"error_count":{"description":"Count of error activities","type":"integer"},"period":{"description":"Time period (1h, 24h, 7d, 30d)","type":"string"},"rejected_count":{"description":"RejectedCount is the number of calls shed by a concurrency limiter before\nthey reached an upstream (spec 093). Counted separately from errors: it is\nproxy backpressure, not an upstream fault, and it is the signal an\noperator right-sizes max_concurrent_requests against.","type":"integer"},"start_time":{"description":"Start of the period (RFC3339)","type":"string"},"success_count":{"description":"Count of successful activities","type":"integer"},"top_servers":{"description":"Top servers by activity count","items":{"$ref":"#/components/schemas/contracts.ActivityTopServer"},"type":"array","uniqueItems":false},"top_tools":{"description":"Top tools by activity count","items":{"$ref":"#/components/schemas/contracts.ActivityTopTool"},"type":"array","uniqueItems":false},"total_count":{"description":"Total activity count","type":"integer"}},"type":"object"},"contracts.ActivityTopServer":{"properties":{"count":{"description":"Activity count","type":"integer"},"name":{"description":"Server name","type":"string"}},"type":"object"},"contracts.ActivityTopTool":{"properties":{"count":{"description":"Activity count","type":"integer"},"server":{"description":"Server name","type":"string"},"tool":{"description":"Tool name","type":"string"}},"type":"object"},"contracts.ActivityType":{"description":"Type of activity","type":"string","x-enum-varnames":["ActivityTypeToolCall","ActivityTypePolicyDecision","ActivityTypeQuarantineChange","ActivityTypeServerChange"]},"contracts.AddFromRegistryRequest":{"properties":{"enabled":{"description":"defaults to true when nil","type":"boolean"},"env":{"additionalProperties":{"type":"string"},"description":"overrides + required-input values","type":"object"},"name":{"description":"optional name override","type":"string"}},"type":"object"},"contracts.AddRegistrySourceRequest":{"properties":{"id":{"description":"derived from the host when empty","type":"string"},"name":{"description":"defaults to the id","type":"string"},"protocol":{"description":"defaults to modelcontextprotocol/registry","type":"string"},"url":{"description":"required https registry URL","type":"string"}},"type":"object"},"contracts.ConfigApplyResult":{"properties":{"applied_immediately":{"type":"boolean"},"changed_fields":{"items":{"type":"string"},"type":"array","uniqueItems":false},"requires_restart":{"type":"boolean"},"restart_reason":{"type":"string"},"success":{"type":"boolean"},"validation_errors":{"items":{"$ref":"#/components/schemas/contracts.ValidationError"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DCRStatus":{"properties":{"attempted":{"type":"boolean"},"error":{"type":"string"},"status_code":{"type":"integer"},"success":{"type":"boolean"}},"type":"object"},"contracts.DeepScanDescriptor":{"description":"DeepScan reports the opt-in \"deep scan\" layer status (Spec 077 US3),\nSEPARATELY from the baseline verdict above. Always emitted on a computed\nsummary — when deep scan is off (the default) it reports enabled=false\nplus any enabled-but-skipped Docker scanners. It never influences Status.","properties":{"available":{"type":"boolean"},"enabled":{"type":"boolean"},"ran":{"type":"boolean"},"scanners_failed":{"items":{"$ref":"#/components/schemas/contracts.DeepScanScannerFailure"},"type":"array","uniqueItems":false},"skipped_scanners":{"description":"SkippedScanners lists Docker scanners the user enabled that are skipped\nbecause security.deep_scan.enabled is false (informational).","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DeepScanScannerFailure":{"properties":{"id":{"type":"string"},"reason":{"type":"string"}},"type":"object"},"contracts.DeprecatedConfigWarning":{"properties":{"field":{"type":"string"},"message":{"type":"string"},"replacement":{"type":"string"}},"type":"object"},"contracts.Diagnostic":{"description":"Spec 044 — structured diagnostic error and stable error code. Both\nare populated when the server is in a failed state and the error\nhas been classified by internal/diagnostics. Healthy servers omit\nthese fields.","properties":{"cause":{"type":"string"},"code":{"type":"string"},"detected_at":{"type":"string"},"docs_url":{"type":"string"},"fix_steps":{"items":{"$ref":"#/components/schemas/contracts.DiagnosticFixStep"},"type":"array","uniqueItems":false},"severity":{"type":"string"},"user_message":{"type":"string"}},"type":"object"},"contracts.DiagnosticFixStep":{"properties":{"command":{"type":"string"},"destructive":{"type":"boolean"},"fixer_key":{"type":"string"},"label":{"type":"string"},"type":{"type":"string"},"url":{"type":"string"}},"type":"object"},"contracts.Diagnostics":{"properties":{"deprecated_configs":{"description":"Deprecated config fields found","items":{"$ref":"#/components/schemas/contracts.DeprecatedConfigWarning"},"type":"array","uniqueItems":false},"docker_status":{"$ref":"#/components/schemas/contracts.DockerStatus"},"missing_secrets":{"description":"Renamed to avoid conflict","items":{"$ref":"#/components/schemas/contracts.MissingSecretInfo"},"type":"array","uniqueItems":false},"oauth_issues":{"description":"OAuth parameter mismatches","items":{"$ref":"#/components/schemas/contracts.OAuthIssue"},"type":"array","uniqueItems":false},"oauth_required":{"items":{"$ref":"#/components/schemas/contracts.OAuthRequirement"},"type":"array","uniqueItems":false},"runtime_warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false},"timestamp":{"type":"string"},"total_issues":{"type":"integer"},"upstream_errors":{"items":{"$ref":"#/components/schemas/contracts.UpstreamError"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DockerStatus":{"properties":{"available":{"type":"boolean"},"error":{"type":"string"},"version":{"type":"string"}},"type":"object"},"contracts.EditRegistrySourceRequest":{"properties":{"name":{"description":"new display name","type":"string"},"servers_url":{"description":"explicit servers-collection URL","type":"string"},"url":{"description":"new base/servers https URL","type":"string"}},"type":"object"},"contracts.ErrorResponse":{"properties":{"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.FindingCounts":{"properties":{"dangerous":{"description":"Tool poisoning, active prompt injection","type":"integer"},"info":{"description":"Low-severity CVEs, informational","type":"integer"},"total":{"type":"integer"},"warning":{"description":"Rug pull, supply chain CVEs with exploits","type":"integer"}},"type":"object"},"contracts.GetConfigResponse":{"properties":{"config":{"description":"The configuration object","type":"object"},"config_path":{"description":"Path to config file","type":"string"}},"type":"object"},"contracts.GetRegistriesResponse":{"properties":{"registries":{"items":{"$ref":"#/components/schemas/contracts.Registry"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetServerLogsResponse":{"properties":{"count":{"type":"integer"},"logs":{"items":{"$ref":"#/components/schemas/contracts.LogEntry"},"type":"array","uniqueItems":false},"server_name":{"type":"string"}},"type":"object"},"contracts.GetServerToolCallsResponse":{"properties":{"server_name":{"type":"string"},"tool_calls":{"items":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetServerToolsResponse":{"properties":{"count":{"type":"integer"},"server_name":{"type":"string"},"tools":{"items":{"$ref":"#/components/schemas/contracts.Tool"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.GetServersResponse":{"properties":{"servers":{"items":{"$ref":"#/components/schemas/contracts.Server"},"type":"array","uniqueItems":false},"stats":{"$ref":"#/components/schemas/contracts.ServerStats"}},"type":"object"},"contracts.GetSessionDetailResponse":{"properties":{"session":{"$ref":"#/components/schemas/contracts.MCPSession"}},"type":"object"},"contracts.GetSessionsResponse":{"properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"sessions":{"items":{"$ref":"#/components/schemas/contracts.MCPSession"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetToolCallDetailResponse":{"properties":{"tool_call":{"$ref":"#/components/schemas/contracts.ToolCallRecord"}},"type":"object"},"contracts.GetToolCallsResponse":{"properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"tool_calls":{"items":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GlobalToolsResponse":{"properties":{"failed_servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"partial":{"type":"boolean"},"stats":{"$ref":"#/components/schemas/contracts.GlobalToolsStats"},"tools":{"items":{"$ref":"#/components/schemas/contracts.Tool"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.GlobalToolsStats":{"properties":{"disabled":{"type":"integer"},"enabled":{"type":"integer"},"pending_approval":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"contracts.HealthStatus":{"description":"Unified health status calculated by the backend","properties":{"action":{"description":"Action is the suggested fix action: \"login\", \"restart\", \"enable\", \"approve\", \"view_logs\", \"set_secret\", \"configure\", or \"\" (none)","type":"string"},"admin_state":{"description":"AdminState indicates the admin state: \"enabled\", \"disabled\", or \"quarantined\"","type":"string"},"detail":{"description":"Detail is an optional longer explanation of the status","type":"string"},"level":{"description":"Level indicates the health level: \"healthy\", \"degraded\", or \"unhealthy\"","type":"string"},"summary":{"description":"Summary is a human-readable status message (e.g., \"Connected (5 tools)\")","type":"string"}},"type":"object"},"contracts.InfoEndpoints":{"description":"Available API endpoints","properties":{"http":{"description":"HTTP endpoint address (e.g., \"127.0.0.1:8080\")","type":"string"},"socket":{"description":"Unix socket path (empty if disabled)","type":"string"}},"type":"object"},"contracts.InfoResponse":{"properties":{"endpoints":{"$ref":"#/components/schemas/contracts.InfoEndpoints"},"launched_by":{"description":"LaunchedBy is the durable launch provenance of the running core (Spec\n092 FR-001a): \"tray\" when a tray spawned it, \"installer\" when the macOS\nPKG postinstall did, \"\" when user-launched or unknown. Always present\n(possibly empty) so a tray can distinguish \"old core, not mine\" from\n\"old core I may supersede\".","type":"string"},"listen_addr":{"description":"Listen address (e.g., \"127.0.0.1:8080\")","type":"string"},"pid":{"description":"PID is the operating-system process id of the running core (Spec 092\nFR-002). A tray that merely ATTACHED to a core holds no Process handle\nfor it, so without this there is no mechanism at all to stop a stale\ncore — the consent action would have nothing to act on and could only\nprint instructions. Paired with LaunchedBy it is what lets a newer tray\nsupersede a core an older tray started.","type":"integer"},"update":{"$ref":"#/components/schemas/contracts.UpdateInfo"},"update_policy":{"$ref":"#/components/schemas/contracts.UpdatePolicy"},"version":{"description":"Current MCPProxy version","type":"string"},"web_ui_url":{"description":"URL to access the web control panel","type":"string"}},"type":"object"},"contracts.IsolationConfig":{"properties":{"cpu_limit":{"type":"string"},"enabled":{"type":"boolean"},"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"memory_limit":{"type":"string"},"network_mode":{"type":"string"},"timeout":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"contracts.IsolationDefaults":{"description":"IsolationDefaults exposes the resolved baseline values that\nwould apply when no per-server override is set. Populated on\nlist/get responses; never consumed on PATCH requests.","properties":{"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"network_mode":{"type":"string"},"runtime_type":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"contracts.LogEntry":{"properties":{"fields":{"type":"object"},"level":{"type":"string"},"message":{"type":"string"},"server":{"type":"string"},"timestamp":{"type":"string"}},"type":"object"},"contracts.MCPSession":{"properties":{"client_name":{"type":"string"},"client_version":{"type":"string"},"end_time":{"type":"string"},"experimental":{"items":{"type":"string"},"type":"array","uniqueItems":false},"has_roots":{"description":"MCP Client Capabilities","type":"boolean"},"has_sampling":{"type":"boolean"},"id":{"type":"string"},"last_activity":{"type":"string"},"start_time":{"type":"string"},"status":{"type":"string"},"tool_call_count":{"type":"integer"},"total_tokens":{"type":"integer"},"work_session_id":{"type":"string"},"workspace_name":{"description":"Workspace / work session (Spec 082). WorkspaceName is the project's\nbasename — the full local path is never exposed. WorkSessionID groups the\nreconnects that make up one stretch of user work.","type":"string"}},"type":"object"},"contracts.MetadataStatus":{"properties":{"authorization_servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"error":{"type":"string"},"found":{"type":"boolean"},"url_checked":{"type":"string"}},"type":"object"},"contracts.MissingSecretInfo":{"properties":{"secret_name":{"type":"string"},"used_by":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.NPMPackageInfo":{"properties":{"exists":{"type":"boolean"},"install_cmd":{"type":"string"}},"type":"object"},"contracts.OAuthConfig":{"properties":{"auth_url":{"type":"string"},"client_id":{"type":"string"},"extra_params":{"additionalProperties":{"type":"string"},"type":"object"},"pkce_enabled":{"type":"boolean"},"redirect_port":{"type":"integer"},"scopes":{"items":{"type":"string"},"type":"array","uniqueItems":false},"token_expires_at":{"description":"When the OAuth token expires","type":"string"},"token_url":{"type":"string"},"token_valid":{"description":"Whether token is currently valid","type":"boolean"}},"type":"object"},"contracts.OAuthErrorDetails":{"description":"Structured discovery/failure details","properties":{"authorization_server_metadata":{"$ref":"#/components/schemas/contracts.MetadataStatus"},"dcr_status":{"$ref":"#/components/schemas/contracts.DCRStatus"},"protected_resource_metadata":{"$ref":"#/components/schemas/contracts.MetadataStatus"},"server_url":{"type":"string"}},"type":"object"},"contracts.OAuthFlowError":{"properties":{"correlation_id":{"description":"Flow tracking ID for log correlation","type":"string"},"debug_hint":{"description":"CLI command for log lookup","type":"string"},"details":{"$ref":"#/components/schemas/contracts.OAuthErrorDetails"},"error_code":{"description":"Machine-readable error code (e.g., OAUTH_NO_METADATA)","type":"string"},"error_type":{"description":"Category of OAuth runtime failure","type":"string"},"message":{"description":"Human-readable error description","type":"string"},"request_id":{"description":"HTTP request ID (from PR #237)","type":"string"},"server_name":{"description":"Server that failed OAuth","type":"string"},"success":{"description":"Always false","type":"boolean"},"suggestion":{"description":"Actionable remediation hint","type":"string"}},"type":"object"},"contracts.OAuthIssue":{"properties":{"documentation_url":{"type":"string"},"error":{"type":"string"},"issue":{"type":"string"},"missing_params":{"items":{"type":"string"},"type":"array","uniqueItems":false},"resolution":{"type":"string"},"server_name":{"type":"string"}},"type":"object"},"contracts.OAuthRequirement":{"properties":{"expires_at":{"type":"string"},"message":{"type":"string"},"server_name":{"type":"string"},"state":{"type":"string"}},"type":"object"},"contracts.OAuthStartResponse":{"properties":{"auth_url":{"description":"Authorization URL (always included for manual use)","type":"string"},"browser_error":{"description":"Error message if browser launch failed","type":"string"},"browser_opened":{"description":"Whether browser launch succeeded","type":"boolean"},"correlation_id":{"description":"UUID for tracking this flow","type":"string"},"message":{"description":"Human-readable status message","type":"string"},"server_name":{"description":"Name of the server being authenticated","type":"string"},"success":{"description":"Always true for successful start","type":"boolean"}},"type":"object"},"contracts.QuarantineStats":{"description":"Tool quarantine metrics for this server","properties":{"blocked_count":{"description":"Number of disabled (blocked) tools","type":"integer"},"changed_count":{"description":"Number of tools whose description/schema changed since approval","type":"integer"},"pending_count":{"description":"Number of newly discovered tools awaiting approval","type":"integer"}},"type":"object"},"contracts.RefreshRegistryResponse":{"properties":{"cleared":{"description":"number of cached entries dropped","type":"integer"},"registry_id":{"type":"string"}},"type":"object"},"contracts.Registry":{"properties":{"count":{"description":"number or string","type":"string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"protocol":{"type":"string"},"provenance":{"description":"Provenance is the trust tag (MCP-866): \"official/trusted\" for built-in\ndefaults, \"custom/unverified\" for user-added registries.","type":"string"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"trusted":{"description":"Trusted indicates whether this is an official, shipped-by-default\nregistry. Trust is derived from membership in the default set, never\nfrom self-assertion in config.","type":"boolean"},"url":{"type":"string"}},"type":"object"},"contracts.RegistryCacheInfo":{"properties":{"age_seconds":{"type":"number"},"stale":{"type":"boolean"}},"type":"object"},"contracts.RegistryUnavailable":{"properties":{"reason":{"type":"string"}},"type":"object"},"contracts.ReplayToolCallRequest":{"properties":{"arguments":{"description":"Modified arguments for replay","type":"object"}},"type":"object"},"contracts.ReplayToolCallResponse":{"properties":{"error":{"description":"Error if replay failed","type":"string"},"new_call_id":{"description":"ID of the newly created call","type":"string"},"new_tool_call":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"replayed_from":{"description":"Original call ID","type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.RepositoryInfo":{"description":"Detected package info","properties":{"npm":{"$ref":"#/components/schemas/contracts.NPMPackageInfo"}},"type":"object"},"contracts.RepositoryServer":{"properties":{"connect_url":{"description":"Alternative connection URL","type":"string"},"created_at":{"type":"string"},"description":{"type":"string"},"id":{"type":"string"},"install_cmd":{"description":"Installation command","type":"string"},"name":{"type":"string"},"registry":{"description":"Which registry this came from","type":"string"},"repository_info":{"$ref":"#/components/schemas/contracts.RepositoryInfo"},"source_code_url":{"description":"Source repository URL","type":"string"},"updated_at":{"type":"string"},"url":{"description":"MCP endpoint for remote servers only","type":"string"}},"type":"object"},"contracts.SearchRegistryServersResponse":{"properties":{"cache":{"$ref":"#/components/schemas/contracts.RegistryCacheInfo"},"query":{"type":"string"},"registry_id":{"type":"string"},"servers":{"items":{"$ref":"#/components/schemas/contracts.RepositoryServer"},"type":"array","uniqueItems":false},"tag":{"type":"string"},"total":{"type":"integer"},"unavailable":{"$ref":"#/components/schemas/contracts.RegistryUnavailable"}},"type":"object"},"contracts.SearchResult":{"properties":{"matches":{"type":"integer"},"score":{"type":"number"},"snippet":{"type":"string"},"tool":{"$ref":"#/components/schemas/contracts.Tool"}},"type":"object"},"contracts.SearchToolsResponse":{"properties":{"query":{"type":"string"},"results":{"items":{"$ref":"#/components/schemas/contracts.SearchResult"},"type":"array","uniqueItems":false},"took":{"type":"string"},"total":{"type":"integer"}},"type":"object"},"contracts.SecurityScanSummary":{"description":"Latest security scan results summary","properties":{"deep_scan":{"$ref":"#/components/schemas/contracts.DeepScanDescriptor"},"finding_counts":{"$ref":"#/components/schemas/contracts.FindingCounts"},"last_scan_at":{"type":"string"},"risk_score":{"description":"0-100","type":"integer"},"scanners_failed":{"type":"integer"},"scanners_run":{"description":"Scanner coverage for the primary (baseline) scan pass — informational only.\nSpec 077 US3 (FR-008/FR-014): Status is derived SOLELY from the\ndeterministic baseline findings; a failed Docker deep scanner no longer\ndowngrades a clean verdict. That failure is surfaced via DeepScan instead.","type":"integer"},"scanners_total":{"type":"integer"},"status":{"description":"\"clean\", \"warnings\", \"dangerous\", \"failed\", \"not_scanned\", \"scanning\"","type":"string"}},"type":"object"},"contracts.Server":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"authenticated":{"description":"OAuth authentication status","type":"boolean"},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges mirrors config.ServerConfig.AutoApproveToolChanges\n(MCP-2930): the per-server intent to auto-approve new/changed tools past\nthe trust baseline. Tri-state *bool — nil means \"never set\" (omitted from\nthe payload), so the Web UI toggle (MCP-2932) can distinguish unset from\nan explicit false. Read-only on the GET path; PATCH/POST accept it via\nAddServerRequest.","type":"boolean"},"command":{"type":"string"},"connected":{"type":"boolean"},"connected_at":{"type":"string"},"connecting":{"type":"boolean"},"created":{"type":"string"},"diagnostic":{"$ref":"#/components/schemas/contracts.Diagnostic"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"error_code":{"type":"string"},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"health":{"$ref":"#/components/schemas/contracts.HealthStatus"},"id":{"type":"string"},"init_timeout":{"description":"InitTimeout mirrors config.ServerConfig.InitTimeout (MCP-3322 / GH #760):\nthe per-server MCP ` + "`" + `initialize` + "`" + ` handshake deadline override. Serialized as\na duration string (e.g. \"120s\"); nil/omitted means \"inherit the global\ndefault\". Surfaced on the GET path so clients can read back a configured\noverride; PATCH/POST accept it via AddServerRequest.","type":"string"},"isolation":{"$ref":"#/components/schemas/contracts.IsolationConfig"},"isolation_defaults":{"$ref":"#/components/schemas/contracts.IsolationDefaults"},"last_error":{"type":"string"},"last_reconnect_at":{"type":"string"},"last_retry_time":{"type":"string"},"max_concurrent_requests":{"description":"Spec 093 (GH #955) — per-server concurrency overrides, scope (c) of\nFR-020. Each setting is tri-state: nil (omitted) means \"inherit\nserver_concurrency_defaults\", 0 disables that setting for this server,\npositive overrides it. Surfaced on the GET path so a caller can read back\nwhat it set; PATCH/POST accept them via AddServerRequest. The effective\nconcurrency for a server is additionally bounded by the global aggregate\nlimiter, which is NOT an inheritance source for these fields.","type":"integer"},"name":{"type":"string"},"oauth":{"$ref":"#/components/schemas/contracts.OAuthConfig"},"oauth_status":{"description":"OAuth status: \"authenticated\", \"expired\", \"error\", \"none\"","type":"string"},"protocol":{"type":"string"},"quarantine":{"$ref":"#/components/schemas/contracts.QuarantineStats"},"quarantined":{"type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"reconnect_count":{"type":"integer"},"reconnect_on_use":{"description":"Attempt reconnection when a tool call targets this disconnected server","type":"boolean"},"retry_count":{"type":"integer"},"security_scan":{"$ref":"#/components/schemas/contracts.SecurityScanSummary"},"should_retry":{"type":"boolean"},"source_registry_id":{"description":"MCP-901 — registry provenance of an upstream that was added from a\nregistry. SourceRegistryID names the source registry (empty for\nmanually-configured servers); SourceRegistryProvenance is the trust tag\nrecorded at add time (\"official/trusted\" or \"custom/unverified\"). Both\nare projected from config.ServerConfig so the approval/quarantine view\ncan render an \"added from \u003cregistry\u003e · unverified\" origin badge. Optional\nand omitted when empty — clients that pre-date this treat them as absent.","type":"string"},"source_registry_provenance":{"type":"string"},"status":{"type":"string"},"token_expires_at":{"description":"When the OAuth token expires (ISO 8601)","type":"string"},"tool_count":{"type":"integer"},"tool_list_token_size":{"description":"Token size for this server's tools","type":"integer"},"trust_mode":{"description":"TrustMode mirrors config.ServerConfig.TrustMode (spec 086): the per-server\ntrust tier (\"auto\"/\"scan\"/\"manual\"). Surfaced on the GET path so clients can\nread back the persisted mode; PATCH/POST accept it via AddServerRequest.\nOmitted when empty (server predates the field / relies on legacy flags).","type":"string"},"updated":{"type":"string"},"url":{"type":"string"},"user_logged_out":{"description":"True if user explicitly logged out (prevents auto-reconnection)","type":"boolean"},"working_dir":{"type":"string"}},"type":"object"},"contracts.ServerActionResponse":{"properties":{"action":{"type":"string"},"async":{"type":"boolean"},"server":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.ServerStats":{"properties":{"connected_servers":{"type":"integer"},"docker_containers":{"type":"integer"},"quarantined_servers":{"type":"integer"},"token_metrics":{"$ref":"#/components/schemas/contracts.ServerTokenMetrics"},"total_servers":{"type":"integer"},"total_tools":{"type":"integer"}},"type":"object"},"contracts.ServerTokenMetrics":{"properties":{"average_query_result_size":{"description":"Typical retrieve_tools output (tokens)","type":"integer"},"per_server_tool_list_sizes":{"additionalProperties":{"type":"integer"},"description":"Token size per server","type":"object"},"saved_tokens":{"description":"Difference","type":"integer"},"saved_tokens_percentage":{"description":"Percentage saved","type":"number"},"total_server_tool_list_size":{"description":"All upstream tools combined (tokens)","type":"integer"}},"type":"object"},"contracts.SuccessResponse":{"properties":{"data":{"type":"object"},"success":{"type":"boolean"}},"type":"object"},"contracts.TokenMetrics":{"description":"Token usage metrics (nil for older records)","properties":{"encoding":{"description":"Encoding used (e.g., cl100k_base)","type":"string"},"estimated_cost":{"description":"Optional cost estimate","type":"number"},"input_tokens":{"description":"Tokens in the request","type":"integer"},"model":{"description":"Model used for tokenization","type":"string"},"output_tokens":{"description":"Tokens in the response","type":"integer"},"total_tokens":{"description":"Total tokens (input + output)","type":"integer"},"truncated_tokens":{"description":"Tokens removed by truncation","type":"integer"},"was_truncated":{"description":"Whether response was truncated","type":"boolean"}},"type":"object"},"contracts.Tool":{"properties":{"annotations":{"$ref":"#/components/schemas/contracts.ToolAnnotation"},"approval_status":{"type":"string"},"config_denied":{"description":"ConfigDenied is true when the tool is denied by the server's static\nenabled_tools / disabled_tools config. The user cannot override this toggle.","type":"boolean"},"description":{"type":"string"},"disabled":{"description":"Disabled mirrors ToolApprovalRecord.Disabled so per-tool enable state is\navailable without a second round-trip to the approvals endpoint. Absent\nin the JSON when false (default) to keep responses compact.","type":"boolean"},"held_reason":{"description":"HeldReason, HeldVerdict and HeldSignals mirror the same-named fields on\nstorage.ToolApprovalRecord: the offline-scan evidence that made\ntrust_mode: scan hold this tool for review (spec 086 FR-018). HeldSignals\nnames the matched deterministic check ids, e.g.\n\"tpa.TPA-2026-0001.hidden_instruction\", so a reviewer can see WHY the tool\nis held. All three are omitted for tools that are not held by the scan gate\n(including every record written before the field existed).","type":"string"},"held_signals":{"items":{"type":"string"},"type":"array","uniqueItems":false},"held_verdict":{"type":"string"},"last_used":{"type":"string"},"name":{"type":"string"},"schema":{"type":"object"},"server_name":{"type":"string"},"usage":{"type":"integer"}},"type":"object"},"contracts.ToolAnnotation":{"description":"Tool behavior hints snapshot","properties":{"destructiveHint":{"type":"boolean"},"idempotentHint":{"type":"boolean"},"openWorldHint":{"type":"boolean"},"readOnlyHint":{"type":"boolean"},"title":{"type":"string"}},"type":"object"},"contracts.ToolCallRecord":{"description":"The new tool call record","properties":{"annotations":{"$ref":"#/components/schemas/contracts.ToolAnnotation"},"arguments":{"description":"Tool arguments","type":"object"},"config_path":{"description":"Active config file path","type":"string"},"duration":{"description":"Duration in nanoseconds","type":"integer"},"error":{"description":"Error message (failure only)","type":"string"},"execution_type":{"description":"\"direct\" or \"code_execution\"","type":"string"},"id":{"description":"Unique identifier","type":"string"},"mcp_client_name":{"description":"MCP client name from InitializeRequest","type":"string"},"mcp_client_version":{"description":"MCP client version","type":"string"},"mcp_session_id":{"description":"MCP session identifier","type":"string"},"metrics":{"$ref":"#/components/schemas/contracts.TokenMetrics"},"parent_call_id":{"description":"Links nested calls to parent code_execution","type":"string"},"request_id":{"description":"Request correlation ID","type":"string"},"response":{"description":"Tool response (success only)","type":"object"},"server_id":{"description":"Server identity hash","type":"string"},"server_name":{"description":"Human-readable server name","type":"string"},"timestamp":{"description":"When the call was made","type":"string"},"tool_name":{"description":"Tool name (without server prefix)","type":"string"}},"type":"object"},"contracts.UpdateInfo":{"description":"Update information (if available)","properties":{"available":{"description":"Whether an update is available","type":"boolean"},"check_error":{"description":"Error message if update check failed","type":"string"},"checked_at":{"description":"When the update check was performed","type":"string"},"install_channel":{"description":"Detected install channel (homebrew, dmg, deb, rpm, docker, go-install, windows-installer, tarball, unknown) — Spec 079 FR-008","type":"string"},"is_prerelease":{"description":"Whether the latest version is a prerelease","type":"boolean"},"latest_version":{"description":"Latest version available (e.g., \"v1.2.3\")","type":"string"},"nudges_suppressed":{"description":"UI surfaces must stay quiet (CI / non-interactive context); machine-readable fields still report the facts — Spec 079 FR-019","type":"boolean"},"release_url":{"description":"URL to the release page","type":"string"},"update_command":{"description":"One-line update command for the channel; only set when an update is available and the channel has one — Spec 079 FR-009","type":"string"}},"type":"object"},"contracts.UpdatePolicy":{"description":"UpdatePolicy is the effective, hot-reloadable update policy (Spec 092\nFR-015). Always present: the ` + "`" + `update` + "`" + ` object above is omitted both when\nupdate checking is disabled AND when no check has produced a result\nyet, so its absence cannot tell a client whether it is allowed to run\nits own (e.g. Sparkle feed) check. This field states the answer.","properties":{"channel":{"description":"Channel is the tracked release channel: \"stable\" or \"rc\".","type":"string"},"enabled":{"description":"Enabled is the effective automatic-check kill switch: update_check.enabled\nwith MCPPROXY_DISABLE_AUTO_UPDATE=true winning over it. A user-initiated\n\"Check for Updates\" stays available regardless.","type":"boolean"},"nudges_suppressed":{"description":"NudgesSuppressed asks UI surfaces to stay quiet (CI / non-interactive)\nwhile machine-readable fields keep reporting the facts.","type":"boolean"}},"type":"object"},"contracts.UpstreamError":{"properties":{"error_message":{"type":"string"},"server_name":{"type":"string"},"timestamp":{"type":"string"}},"type":"object"},"contracts.UsageAggregateResponse":{"properties":{"freshness_ms":{"description":"age of the underlying snapshot in ms","type":"integer"},"generated_at":{"type":"string"},"other":{"$ref":"#/components/schemas/contracts.UsageOtherBucket"},"timeline":{"items":{"$ref":"#/components/schemas/contracts.UsageTimeBucket"},"type":"array","uniqueItems":false},"token_source":{"description":"\"bytes\" (size-based proxy, FR-006)","type":"string"},"tokens_saved":{"description":"echoed from ServerTokenMetrics (FR-007)","type":"integer"},"tokens_saved_percentage":{"type":"number"},"tools":{"items":{"$ref":"#/components/schemas/contracts.UsageToolStat"},"type":"array","uniqueItems":false},"window":{"type":"string"}},"type":"object"},"contracts.UsageOtherBucket":{"description":"present only when the list was truncated to top-N","properties":{"calls":{"type":"integer"},"tools_folded":{"type":"integer"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.UsageTimeBucket":{"properties":{"calls":{"type":"integer"},"errors":{"type":"integer"},"start":{"type":"string"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.UsageToolStat":{"properties":{"avg_req_bytes":{"description":"null when no sized request calls","type":"integer"},"avg_resp_bytes":{"description":"null when sized_calls == 0 (only legacy 0-byte calls)","type":"integer"},"blocked":{"type":"integer"},"calls":{"type":"integer"},"error_rate":{"type":"number"},"errors":{"type":"integer"},"last_used":{"type":"string"},"p50_ms":{"type":"integer"},"p95_ms":{"type":"integer"},"rejected":{"description":"spec 093: shed by a concurrency limit; never executed, so excluded from calls/latency","type":"integer"},"server":{"type":"string"},"sized_calls":{"description":"calls with known response size (basis for avg_resp_bytes)","type":"integer"},"tool":{"type":"string"},"total_req_bytes":{"type":"integer"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.ValidateConfigResponse":{"properties":{"errors":{"items":{"$ref":"#/components/schemas/contracts.ValidationError"},"type":"array","uniqueItems":false},"valid":{"type":"boolean"}},"type":"object"},"contracts.ValidationError":{"properties":{"field":{"type":"string"},"message":{"type":"string"}},"type":"object"},"data":{"properties":{"data":{"$ref":"#/components/schemas/contracts.InfoResponse"}},"type":"object"},"httpapi.AddServerRequest":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges is the per-server intent to auto-approve\nnew/changed tools past the trust baseline (MCP-2930). Tri-state *bool:\na nil pointer means \"leave unchanged\" on PATCH; a present value\n(including false) is applied. Mirrors config.ServerConfig's *bool\nsemantics — do NOT collapse to a plain bool, or an omitted field would\nsilently reset a previously-set value.","type":"boolean"},"command":{"type":"string"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"init_timeout":{"description":"InitTimeout is the per-server MCP ` + "`" + `initialize` + "`" + ` handshake deadline override\n(MCP-3322 / GH #760), serialized as a duration string (e.g. \"120s\"). A nil\npointer means \"leave unchanged\" on PATCH; a present value is applied.\nMirrors config.ServerConfig.InitTimeout's *Duration tri-state.","type":"string"},"isolation":{"$ref":"#/components/schemas/httpapi.IsolationRequest"},"max_concurrent_requests":{"description":"MaxConcurrentRequests / QueueSize / QueueTimeout are the per-server\nconcurrency overrides (spec 093 / GH #955, FR-020 scope (c)). Each is\ntri-state: a nil pointer means \"leave unchanged\" on PATCH and \"inherit\nserver_concurrency_defaults\" on create; an explicit 0 disables that\nsetting for this server; a positive value overrides it. Do NOT collapse\nthem to plain values — an omitted field would then silently reset a\nconfigured limit.","type":"integer"},"name":{"type":"string"},"protocol":{"type":"string"},"quarantined":{"type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"reconnect_on_use":{"type":"boolean"},"trust_mode":{"description":"TrustMode is the per-server trust tier (spec 086): \"auto\", \"scan\", or\n\"manual\". Empty means \"leave unchanged\" on PATCH (and inherit the migrated\ndefault on create). A non-empty value is applied to ServerConfig.TrustMode\nand resolved by EffectiveTrustMode (an unrecognized value fails closed to\nmanual). This is the REST seam for changing the trust tier via\nPOST/PATCH /api/v1/servers.","type":"string"},"url":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"httpapi.CanonicalConfigPath":{"properties":{"description":{"description":"Brief description","type":"string"},"exists":{"description":"Whether the file exists","type":"boolean"},"format":{"description":"Format identifier (e.g., \"claude_desktop\")","type":"string"},"name":{"description":"Display name (e.g., \"Claude Desktop\")","type":"string"},"os":{"description":"Operating system (darwin, windows, linux)","type":"string"},"path":{"description":"Full path to the config file","type":"string"}},"type":"object"},"httpapi.CanonicalConfigPathsResponse":{"properties":{"os":{"description":"Current operating system","type":"string"},"paths":{"description":"List of canonical config paths","items":{"$ref":"#/components/schemas/httpapi.CanonicalConfigPath"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ConnectConflictResponse":{"properties":{"action":{"description":"already_exists | precondition_failed","type":"string"},"data":{"$ref":"#/components/schemas/connect.ConnectResult"},"error":{"description":"Human-readable message","type":"string"},"success":{"description":"Always false","type":"boolean"}},"type":"object"},"httpapi.ConnectRequest":{"properties":{"force":{"description":"Overwrite existing entry","type":"boolean"},"precondition_token":{"description":"PreconditionToken is the opaque token from the preview this write was\nconfirmed against (Spec 091 FR-005). When present, the core rechecks it\nat write time and responds 409 with action \"precondition_failed\" —\nwriting nothing — if the config or the entry MCPProxy would write has\ndrifted since; the caller then re-previews instead of retrying. Absent\nmeans exactly the pre-091 behavior. A replace-classified flow sends this\nTOGETHER with force=true: the token, not the absence of force, is the\noverwrite safety.","type":"string"},"server_name":{"description":"Defaults to \"mcpproxy\"","type":"string"}},"type":"object"},"httpapi.ImportFromPathRequest":{"properties":{"format":{"description":"Optional format hint","type":"string"},"path":{"description":"File path to import from","type":"string"},"rename":{"additionalProperties":{"type":"string"},"description":"Rename maps a server name → new name. Applied after parsing so the\ncaller can disambiguate cross-source name collisions (Spec 046 v2 —\ne.g. \"mcpproxy\" → \"mcpproxy_claude_code\"). Keys are matched against\neither the raw source name (OriginalName) or the sanitized name shown\nin the preview (Server.Name); these differ for names that need\nsanitizing (e.g. \"Figma Desktop\" → \"Figma_Desktop\"). Keys not present\nin the imported set are ignored.","type":"object"},"server_names":{"description":"Optional: import only these servers","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportRequest":{"properties":{"content":{"description":"Raw JSON or TOML content","type":"string"},"format":{"description":"Optional format hint","type":"string"},"server_names":{"description":"Optional: import only these servers","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportResponse":{"properties":{"failed":{"items":{"$ref":"#/components/schemas/configimport.FailedServer"},"type":"array","uniqueItems":false},"format":{"type":"string"},"format_name":{"type":"string"},"imported":{"items":{"$ref":"#/components/schemas/httpapi.ImportedServerResponse"},"type":"array","uniqueItems":false},"skipped":{"items":{"$ref":"#/components/schemas/configimport.SkippedServer"},"type":"array","uniqueItems":false},"summary":{"$ref":"#/components/schemas/configimport.ImportSummary"},"warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportedServerResponse":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"command":{"type":"string"},"fields_skipped":{"items":{"type":"string"},"type":"array","uniqueItems":false},"name":{"type":"string"},"original_name":{"type":"string"},"protocol":{"type":"string"},"source_format":{"type":"string"},"url":{"type":"string"},"warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.IsolationRequest":{"description":"Isolation carries per-server Docker isolation overrides (image,\nnetwork_mode, extra_args, working_dir, enabled). A nil pointer\nmeans \"do not touch isolation config\"; an empty-but-present\nobject on PATCH intentionally clears the overrides.","properties":{"enabled":{"type":"boolean"},"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"network_mode":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"httpapi.OnboardingMarkRequest":{"properties":{"connect_step_status":{"description":"ConnectStepStatus is one of: \"\", \"completed\", \"skipped\". Empty\npreserves the existing value. The stored enum is wider (Spec 080\nFR-001): a \"skipped\" request for a previously untouched connect step\nis upgraded server-side to \"completed_external\" when the install\nshows positive evidence of an external connection (Spec 080 FR-002).\n\"completed_external\" is NOT accepted from clients — it must never be\npersisted without that server-verified evidence (edge case: \"never\nguess completed_external without positive evidence\").","type":"string"},"engaged":{"description":"Engaged marks the wizard as engaged (completed or explicitly skipped).\nOnce true, the wizard does not auto-show again.","type":"boolean"},"mark_shown":{"description":"MarkShown records the wizard's first display time if not already set.","type":"boolean"},"server_step_status":{"description":"ServerStepStatus is one of: \"\", \"completed\", \"skipped\". Empty\npreserves the existing value.","type":"string"}},"type":"object"},"httpapi.SetActiveProfileRequest":{"properties":{"active_profile":{"type":"string"},"profile":{"type":"string"}},"type":"object"},"httpapi.UndoConnectRequest":{"properties":{"backup_name":{"description":"BackupName is the bare filename (filepath.Base) of the backup returned as\nbackup_path by the preceding connect — a name, never a path. Undo resolves\nthe full path server-side by joining it with the client's own config\ndirectory, so a client-supplied value can never contribute a directory\ncomponent (traversal is impossible by construction). Empty means the\nconnect created the file (no prior file existed), so undo removes it.","type":"string"},"server_name":{"description":"Defaults to \"mcpproxy\"","type":"string"}},"type":"object"},"management.BulkOperationResult":{"properties":{"errors":{"additionalProperties":{"type":"string"},"description":"Map of server name to error message","type":"object"},"failed":{"description":"Number of failed operations","type":"integer"},"successful":{"description":"Number of successful operations","type":"integer"},"total":{"description":"Total servers processed","type":"integer"}},"type":"object"},"observability.HealthResponse":{"properties":{"components":{"items":{"$ref":"#/components/schemas/observability.HealthStatus"},"type":"array","uniqueItems":false},"status":{"description":"\"healthy\" or \"unhealthy\"","type":"string"},"timestamp":{"type":"string"}},"type":"object"},"observability.HealthStatus":{"properties":{"error":{"type":"string"},"latency":{"type":"string"},"name":{"type":"string"},"status":{"description":"\"healthy\" or \"unhealthy\"","type":"string"}},"type":"object"},"observability.ReadinessResponse":{"properties":{"components":{"items":{"$ref":"#/components/schemas/observability.HealthStatus"},"type":"array","uniqueItems":false},"status":{"description":"\"ready\" or \"not_ready\"","type":"string"},"timestamp":{"type":"string"}},"type":"object"},"secureenv.EnvConfig":{"description":"Environment configuration for secure variable filtering","properties":{"allowed_system_vars":{"items":{"type":"string"},"type":"array","uniqueItems":false},"custom_vars":{"additionalProperties":{"type":"string"},"type":"object"},"enhance_path":{"description":"Enable PATH enhancement for Launchd scenarios","type":"boolean"},"forward_proxy_env":{"description":"ForwardProxyEnv opts in to forwarding the ambient HTTP(S)/ALL/NO/FTP proxy\nenvironment variables to spawned upstream servers (MCP-2769). It is OFF by\ndefault and deliberately kept out of the AllowedSystemVars default list:\nproxy URLs frequently carry credentials (http://user:pass@proxy), so\nforwarding them to every stdio upstream is a credential-leak risk. When\nenabled, values are forwarded with their userinfo (credentials) redacted.","type":"boolean"},"inherit_system_safe":{"type":"boolean"}},"type":"object"},"telemetry.FeedbackContext":{"properties":{"arch":{"type":"string"},"connected_server_count":{"type":"integer"},"edition":{"type":"string"},"os":{"type":"string"},"routing_mode":{"type":"string"},"server_count":{"type":"integer"},"version":{"type":"string"}},"type":"object"},"telemetry.FeedbackRequest":{"properties":{"category":{"description":"bug, feature, other","type":"string"},"context":{"$ref":"#/components/schemas/telemetry.FeedbackContext"},"email":{"type":"string"},"message":{"type":"string"}},"type":"object"},"telemetry.FeedbackResponse":{"properties":{"error":{"type":"string"},"issue_url":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}},"securitySchemes":{"ApiKeyAuth":{"description":"API key authentication via query parameter. Use ?apikey=your-key","in":"query","name":"apikey","type":"apiKey"}}},
"info": {"contact":{"name":"MCPProxy Support","url":"https://github.com/smart-mcp-proxy/mcpproxy-go"},"description":"{{escape .Description}}","license":{"name":"MIT","url":"https://opensource.org/licenses/MIT"},"title":"{{.Title}}","version":"{{.Version}}"},
"externalDocs": {"description":"","url":""},
- "paths": {"/api/v1/activity":{"get":{"description":"Returns paginated list of activity records with optional filtering","parameters":[{"description":"Filter by activity type(s), comma-separated for multiple (Spec 024)","in":"query","name":"type","schema":{"enum":["tool_call","policy_decision","quarantine_change","server_change","system_start","system_stop","internal_tool_call","config_change"],"type":"string"}},{"description":"Filter by server name","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter by tool name","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter by MCP transport session ID","in":"query","name":"session_id","schema":{"type":"string"}},{"description":"Filter by work session (one client, one project, across reconnects)","in":"query","name":"work_session_id","schema":{"type":"string"}},{"description":"Filter by status","in":"query","name":"status","schema":{"enum":["success","error","blocked"],"type":"string"}},{"description":"Filter by intent operation type (Spec 018)","in":"query","name":"intent_type","schema":{"enum":["read","write","destructive"],"type":"string"}},{"description":"Filter by HTTP request ID for log correlation (Spec 021)","in":"query","name":"request_id","schema":{"type":"string"}},{"description":"Include successful call_tool_* internal tool calls (default: false, excluded to avoid duplicates)","in":"query","name":"include_call_tool","schema":{"type":"boolean"}},{"description":"Filter by sensitive data detection (true=has detections, false=no detections)","in":"query","name":"sensitive_data","schema":{"type":"boolean"}},{"description":"Filter by specific detection type (e.g., 'aws_access_key', 'credit_card')","in":"query","name":"detection_type","schema":{"type":"string"}},{"description":"Filter by severity level","in":"query","name":"severity","schema":{"enum":["critical","high","medium","low"],"type":"string"}},{"description":"Filter by agent token name (Spec 028)","in":"query","name":"agent","schema":{"type":"string"}},{"description":"Filter by auth type (Spec 028)","in":"query","name":"auth_type","schema":{"enum":["admin","agent"],"type":"string"}},{"description":"Filter activities after this time (RFC3339)","in":"query","name":"start_time","schema":{"type":"string"}},{"description":"Filter activities before this time (RFC3339)","in":"query","name":"end_time","schema":{"type":"string"}},{"description":"Maximum records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Pagination offset (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Omit arguments, response and metadata except a contextual whitelist (intent.reason, intent.operation_type, decision, reason, client_name, client_version) (default: false). For clients that render summary fields only; has_sensitive_data is still derived before metadata is dropped.","in":"query","name":"exclude_payloads","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"List activity records","tags":["Activity"]}},"/api/v1/activity/export":{"get":{"description":"Exports activity records in JSON Lines or CSV format for compliance","parameters":[{"description":"Export format: json (default) or csv","in":"query","name":"format","schema":{"type":"string"}},{"description":"Filter by activity type","in":"query","name":"type","schema":{"type":"string"}},{"description":"Filter by server name","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter by tool name","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter by MCP transport session ID","in":"query","name":"session_id","schema":{"type":"string"}},{"description":"Filter by work session (one client, one project, across reconnects)","in":"query","name":"work_session_id","schema":{"type":"string"}},{"description":"Filter by status","in":"query","name":"status","schema":{"type":"string"}},{"description":"Filter activities after this time (RFC3339)","in":"query","name":"start_time","schema":{"type":"string"}},{"description":"Filter activities before this time (RFC3339)","in":"query","name":"end_time","schema":{"type":"string"}},{"description":"Maximum records to export (1-50000, default 10000)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Pagination offset (default 0)","in":"query","name":"offset","schema":{"type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"type":"string"}},"application/x-ndjson":{"schema":{"type":"string"}},"text/csv":{"schema":{"type":"string"}}},"description":"Streamed activity records"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Export activity records","tags":["Activity"]}},"/api/v1/activity/summary":{"get":{"description":"Returns aggregated activity statistics for a time period","parameters":[{"description":"Time period: 1h, 24h (default), 7d, 30d","in":"query","name":"period","schema":{"type":"string"}},{"description":"Group by: server, tool (optional)","in":"query","name":"group_by","schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get activity summary statistics","tags":["Activity"]}},"/api/v1/activity/usage":{"get":{"description":"Returns the actor-owned usage aggregate (per-tool rollup + timeline + tokens-saved headline) for the Web UI usage graphs (Spec 069). Served from an in-memory snapshot — never a per-request full-log scan. Per-tool metrics are lifetime-cumulative; ` + "`" + `window` + "`" + ` scopes the timeline and filters the tool list to tools active within the span.","parameters":[{"description":"Time window for timeline + tool-list membership","in":"query","name":"window","schema":{"enum":["24h","7d","all"],"type":"string"}},{"description":"Filter to one server","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter to one tool","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter to tools with activity of this status","in":"query","name":"status","schema":{"enum":["success","error","blocked"],"type":"string"}},{"description":"Top-N tools by sort key; remainder folded into 'other' (default 20)","in":"query","name":"top","schema":{"type":"integer"}},{"description":"Ranking key for the per-tool list","in":"query","name":"sort","schema":{"enum":["calls","resp_bytes","error_rate","p95"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get usage statistics aggregate","tags":["Activity"]}},"/api/v1/activity/{id}":{"get":{"description":"Returns full details for a single activity record","parameters":[{"description":"Activity record ID (ULID)","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Not Found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get activity record details","tags":["Activity"]}},"/api/v1/annotations/coverage":{"get":{"description":"Reports how many upstream tools have MCP annotations vs don't, broken down by server","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Annotation coverage report"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get annotation coverage report","tags":["annotations"]}},"/api/v1/config":{"get":{"description":"Retrieves the current MCPProxy configuration including all server definitions, global settings, and runtime parameters","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetConfigResponse"}}},"description":"Configuration retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get current configuration","tags":["config"]},"patch":{"description":"Deep-merges only the fields present in the request body onto the live in-memory configuration and routes the result through the existing apply pipeline (validation, change detection, disk persistence, hot-reload). Fields the client omits — including masked secrets such as ` + "`" + `api_key` + "`" + ` and secret request headers — are preserved verbatim. Nested objects are merged recursively; arrays and scalars replace wholesale.","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}},"description":"Partial configuration with only the fields to change","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Configuration patch applied (inspect validation_errors for rejected values)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload or empty patch"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate configuration)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to read or apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Partially update configuration","tags":["config"]}},"/api/v1/config/apply":{"post":{"description":"Applies a new MCPProxy configuration. Validates and persists the configuration to disk. Some changes apply immediately, while others may require a restart. Returns detailed information about applied changes and restart requirements.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/config.Config"}}},"description":"Configuration to apply","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Configuration applied successfully with change details"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate configuration)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Apply configuration","tags":["config"]}},"/api/v1/config/docker-isolation":{"patch":{"description":"Convenience endpoint to flip ` + "`" + `docker_isolation.enabled` + "`" + ` without resending the full config. Persists to disk via the existing config writer — the file watcher then hot-reloads the change. Returns the new state and whether a restart is required for existing connections to pick it up.","requestBody":{"content":{"application/json":{"schema":{"properties":{"enabled":{"type":"boolean"}},"type":"object"}}},"description":"New isolation state","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Isolation toggle applied"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate configuration)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Toggle global Docker isolation","tags":["config"]}},"/api/v1/config/validate":{"post":{"description":"Validates a provided MCPProxy configuration without applying it. Checks for syntax errors, invalid server definitions, conflicting settings, and other configuration issues.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/config.Config"}}},"description":"Configuration to validate","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ValidateConfigResponse"}}},"description":"Configuration validation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Validation failed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Validate configuration","tags":["config"]}},"/api/v1/connect":{"get":{"description":"Returns the connection status for all known MCP client applications.\nEach entry indicates whether the client config file exists and whether\nMCPProxy is currently registered in it.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"List of ClientStatus objects"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List client connection status","tags":["connect"]}},"/api/v1/connect/{client}":{"delete":{"description":"Remove the MCPProxy entry from the specified client's configuration file.\nCreates a backup of the existing config before modifying.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectRequest"}}},"description":"Optional parameters (server_name)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client or entry not found"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disconnect MCPProxy from a client","tags":["connect"]},"get":{"description":"Resolves one client's status by reading its config file on demand.\nThis is the only Connect endpoint that opens a client config file, so\non macOS it is the sole place an App-Data privacy prompt may legitimately\nappear (scoped to this user action). Resolves access_state to\naccessible|absent|denied|malformed and populates remediation when denied.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ClientStatus"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get a single client's connection status (on-demand)","tags":["connect"]},"post":{"description":"Register MCPProxy as an MCP server in the specified client's configuration file.\nCreates a backup of the existing config before modifying.\nOptionally accepts precondition_token from a preview (Spec 091): when supplied,\nthe core rechecks the raw pre-write state and the entry it would write, and\nrefuses a drifted write with 409 before taking any backup. The 409 body's\naction discriminates the two conflict kinds: \"precondition_failed\" (stale\npreview — re-preview, do not retry) vs \"already_exists\" (entry present — pass\nforce=true). force=true never rescues a stale token.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectRequest"}}},"description":"Optional connection parameters (server_name, force, precondition_token)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectConflictResponse"}}},"description":"Conflict: action=already_exists (use force=true) or action=precondition_failed (preview is stale; re-preview)"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Connect MCPProxy to a client","tags":["connect"]}},"/api/v1/connect/{client}/preview":{"get":{"description":"Returns the exact entry a subsequent connect would add to the client's\nconfig — target path, server key, entry name, and entry contents — WITHOUT\nmodifying the file or creating a backup (Spec 078 US1). The embedded API key\nis masked in the payload; contains_api_key flags that a credential is written.\nentry_exists distinguishes a create from an overwrite of a same-named entry.\nReads the config on demand to classify create-vs-overwrite, so on macOS this\nmay raise an App-Data privacy prompt; a denial returns 403 + remediation.\nSpec 091 adds three fields: existing_entry_summary (present only when\nentry_exists — a sanitized, non-secret projection of the entry being replaced:\nits name, type, endpoint with query/userinfo stripped, command, and header and\nenv NAMES, never values); precondition_token (always present — an opaque keyed\ndigest of the raw pre-write state and the pending entry, echoed back on POST\nconnect to detect drift); and connect_refusal (present when the write would\nrefuse regardless of intent, e.g. a non-create-capable client with no config —\ntreat its presence as \"Connect unavailable\").","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}},{"description":"Entry name to preview (defaults to mcpproxy); mirror the value passed to POST connect","in":"query","name":"server_name","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectPreview"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Preview the change a connect would make (no write)","tags":["connect"]}},"/api/v1/connect/{client}/undo":{"post":{"description":"Reverts the connect that produced the named backup (Spec 078 US3):\nrestores the client config byte-for-byte from that backup, or — when\nbackup_name is empty because the connect created the file — deletes the\ncreated file. backup_name is the bare filename of the backup the connect\nreturned (never a path); undo resolves the full path server-side inside\nthe client's own config directory, so a client value cannot escape it.\nRefuses with 409 when the config changed since the connect (undo never\nclobbers later edits; use DELETE /connect/{client} for a surgical entry\nremoval instead). Takes its own safety backup first; its path is returned\nas backup_path in the result.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.UndoConnectRequest"}}},"description":"Undo parameters (server_name, backup_name = the bare filename of the backup the preceding connect returned)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult (action restored|deleted)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (e.g. backup_name is a path, or not a backup of this client's config)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client or backup no longer exists"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Config changed since connect; undo refused"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Undo a connect, restoring the pre-connect config","tags":["connect"]}},"/api/v1/diagnostics":{"get":{"description":"Get comprehensive health diagnostics including upstream errors, OAuth requirements, missing secrets, and Docker status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.Diagnostics"}}},"description":"Health diagnostics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get health diagnostics","tags":["diagnostics"]}},"/api/v1/docker/status":{"get":{"description":"Retrieve current Docker availability and recovery status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Docker status information"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get Docker status","tags":["docker"]}},"/api/v1/doctor":{"get":{"description":"Get comprehensive health diagnostics including upstream errors, OAuth requirements, missing secrets, and Docker status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.Diagnostics"}}},"description":"Health diagnostics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get health diagnostics","tags":["diagnostics"]}},"/api/v1/feedback":{"post":{"description":"Submit a bug report, feature request, or general feedback","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/telemetry.FeedbackRequest"}}},"description":"Feedback request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/telemetry.FeedbackResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Bad Request"},"429":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Too Many Requests"},"500":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyAuth":[]}],"summary":"Submit feedback","tags":["feedback"]}},"/api/v1/index/search":{"get":{"description":"Search across all upstream MCP server tools using BM25 keyword search","parameters":[{"description":"Search query","in":"query","name":"q","required":true,"schema":{"type":"string"}},{"description":"Maximum number of results","in":"query","name":"limit","schema":{"default":10,"maximum":100,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SearchToolsResponse"}}},"description":"Search results"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing query parameter)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Search for tools","tags":["tools"]}},"/api/v1/info":{"get":{"description":"Get essential server metadata including version, web UI URL, endpoint addresses, and update availability\nThis endpoint is designed for tray-core communication and version checking\nUse refresh=true query parameter to force an immediate update check against GitHub\nThe launched_by field reports durable launch provenance (\"tray\", \"installer\", or \"\" for user-launched/unknown)","parameters":[{"description":"Force immediate update check against GitHub","in":"query","name":"refresh","schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Server information with optional update info"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server information","tags":["status"]}},"/api/v1/onboarding/mark":{"post":{"description":"Updates wizard engagement and per-step status. Once engaged is\ntrue, the wizard does not auto-show again, even if state regresses.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.OnboardingMarkRequest"}}},"description":"Mark request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Updated OnboardingStateResponse"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Mark onboarding wizard state (Spec 046)","tags":["onboarding"]}},"/api/v1/onboarding/state":{"get":{"description":"Returns the wizard engagement record alongside live predicates\n(whether any client is connected, whether any server is configured),\nplus a derived ShouldShowWizard flag the frontend can rely on.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"OnboardingStateResponse"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get onboarding wizard state and predicates (Spec 046)","tags":["onboarding"]}},"/api/v1/profiles":{"get":{"description":"List all configured profiles with their effective servers and indexed tool count (Profiles v2). A profile scopes tool discovery and calls to a named subset of upstream servers.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Profile list"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Configuration unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List configured profiles","tags":["profiles"]}},"/api/v1/profiles/active":{"get":{"description":"Get the server-level default active profile used by UI surfaces (Web UI / tray). Empty string means \"all servers\". Note: within a live MCP session, the set_profile tool selection takes precedence over this default.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Active profile"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get the default active profile","tags":["profiles"]},"put":{"description":"Set the server-level default active profile for UI surfaces. The slug must match a configured profile; pass an empty string to clear. This does not affect live MCP sessions, which use the set_profile tool.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.SetActiveProfileRequest"}}},"description":"Profile slug to activate (empty clears)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Active profile updated"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid request body"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown profile"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Set the default active profile","tags":["profiles"]}},"/api/v1/registries":{"get":{"description":"Retrieves list of all MCP server registries that can be browsed for discovering and installing new upstream servers. Includes registry metadata, server counts, and API endpoints.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetRegistriesResponse"}}},"description":"Registries retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to list registries"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List available MCP server registries","tags":["registries"]},"post":{"description":"Adds a generic modelcontextprotocol/registry v0.1 https endpoint as a custom registry (MCP-866). The source is always tagged custom/unverified, so every server discovered through it lands quarantined and can never skip quarantine.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.AddRegistrySourceRequest"}}},"description":"Registry source (https url + optional protocol/id/name)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source added"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"invalid_registry_url"},"403":{"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/contracts.ErrorResponse"},{"$ref":"#/components/schemas/contracts.ErrorResponse"}]}}},"description":"Forbidden (agent tokens cannot mutate registries)"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin | duplicate_registry"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add a user-supplied registry source","tags":["registries"]}},"/api/v1/registries/{id}":{"delete":{"description":"Removes a custom/unverified registry previously added via add-source (MCP-1057). Built-in registries are refused with registry_shadows_builtin; an unknown id yields registry_not_found. The change is persisted copy-on-write.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source removed"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registries_locked"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Remove a user-added custom registry source","tags":["registries"]},"put":{"description":"Updates a custom registry previously added via add-source (MCP-1072): name, url, servers-url. Empty fields are left unchanged. Built-in registries are refused with registry_shadows_builtin; an unknown id yields registry_not_found; a non-https url yields invalid_registry_url. The change is persisted copy-on-write.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.EditRegistrySourceRequest"}}},"description":"Fields to update (name/url/servers_url; empty = unchanged)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source updated"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required | invalid_registry_url"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registries_locked"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Edit a user-added custom registry source","tags":["registries"]}},"/api/v1/registries/{id}/refresh":{"post":{"description":"Invalidates the cached server lists for a registry so the next search re-fetches fresh data from the source (spec 070 FR-007). Returns how many cache entries were dropped.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.RefreshRegistryResponse"}}},"description":"Registry cache refreshed"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to refresh registry cache"}},"summary":"Refresh a registry's cached server list","tags":["registries"]}},"/api/v1/registries/{id}/servers":{"get":{"description":"Searches for MCP servers within a specific registry by keyword or tag. Returns server metadata including installation commands, source code URLs, and npm package information for easy discovery and installation.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Search query keyword","in":"query","name":"q","schema":{"type":"string"}},{"description":"Filter by tag","in":"query","name":"tag","schema":{"type":"string"}},{"description":"Maximum number of results (default 10)","in":"query","name":"limit","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SearchRegistryServersResponse"}}},"description":"Servers retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to search servers"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Search MCP servers in a registry","tags":["registries"]}},"/api/v1/registries/{id}/servers/{serverId}/add":{"post":{"description":"Resolves a registry server reference server-side, re-derives a validated config, and persists it quarantined (spec 070 keystone). The client never sends a config blob — command/args/url and the quarantine flag are derived from the registry entry, not the request.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Server ID within the registry","in":"path","name":"serverId","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.AddFromRegistryRequest"}}},"description":"Optional overrides (name, env, enabled)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server added (quarantined)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"no_install_info | missing_required_input | duplicate_name"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot add servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found | server_not_found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add an upstream server from a registry reference","tags":["registries"]}},"/api/v1/routing":{"get":{"description":"Get the current routing mode and available MCP endpoints","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Routing mode information"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get routing mode information","tags":["status"]}},"/api/v1/secrets":{"post":{"description":"Stores a secret value in the operating system's secure keyring. The secret can then be referenced in configuration using ${keyring:secret-name} syntax. Automatically notifies runtime to restart affected servers.","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret stored successfully with reference syntax"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload, missing name/value, or unsupported type"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver not available or failed to store secret"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Store a secret in OS keyring","tags":["secrets"]}},"/api/v1/secrets/{name}":{"delete":{"description":"Deletes a secret from the operating system's secure keyring. Automatically notifies runtime to restart affected servers. Only keyring type is supported for security.","parameters":[{"description":"Name of the secret to delete","in":"path","name":"name","required":true,"schema":{"type":"string"}},{"description":"Secret type (only 'keyring' supported, defaults to 'keyring')","in":"query","name":"type","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret deleted successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Missing secret name or unsupported type"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver not available or failed to delete secret"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Delete a secret from OS keyring","tags":["secrets"]}},"/api/v1/servers":{"get":{"description":"Get a list of all configured upstream MCP servers with their connection status and statistics","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServersResponse"}}},"description":"Server list with statistics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List all upstream MCP servers","tags":["servers"]},"post":{"description":"Add a new MCP upstream server to the configuration. New servers are quarantined by default for security.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.AddServerRequest"}}},"description":"Server configuration","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server added successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid configuration"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Conflict - server with this name already exists"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add a new upstream server","tags":["servers"]}},"/api/v1/servers/disable_all":{"post":{"description":"Disable all configured upstream MCP servers with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk disable results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disable all servers","tags":["servers"]}},"/api/v1/servers/enable_all":{"post":{"description":"Enable all configured upstream MCP servers with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk enable results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable all servers","tags":["servers"]}},"/api/v1/servers/import":{"post":{"description":"Import MCP server configurations from a Claude Desktop, Claude Code, Cursor IDE, Codex CLI, or Gemini CLI configuration file","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}},{"description":"Force format (claude-desktop, claude-code, cursor, codex, gemini)","in":"query","name":"format","schema":{"type":"string"}},{"description":"Comma-separated list of server names to import","in":"query","name":"server_names","schema":{"type":"string"}}],"requestBody":{"content":{"multipart/form-data":{"schema":{"type":"file"}}},"description":"Configuration file to import","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid file or format"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from uploaded configuration file","tags":["servers"]}},"/api/v1/servers/import/json":{"post":{"description":"Import MCP server configurations from raw JSON or TOML content (useful for pasting configurations)","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportRequest"}}},"description":"Import request with content","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid content or format"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from JSON/TOML content","tags":["servers"]}},"/api/v1/servers/import/path":{"post":{"description":"Import MCP server configurations by reading a file from the server's filesystem","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportFromPathRequest"}}},"description":"Import request with file path","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid path or format"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"File not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from a file path","tags":["servers"]}},"/api/v1/servers/import/paths":{"get":{"description":"Returns well-known configuration file paths for supported formats with existence check","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.CanonicalConfigPathsResponse"}}},"description":"Canonical config paths"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get canonical config file paths","tags":["servers"]}},"/api/v1/servers/reconnect":{"post":{"description":"Force reconnection to all upstream MCP servers","parameters":[{"description":"Reason for reconnection","in":"query","name":"reason","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"All servers reconnected successfully"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Reconnect all servers","tags":["servers"]}},"/api/v1/servers/restart_all":{"post":{"description":"Restart all configured upstream MCP servers sequentially with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk restart results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Restart all servers","tags":["servers"]}},"/api/v1/servers/{id}":{"delete":{"description":"Remove an MCP upstream server from the configuration. This stops the server if running and removes it from config.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server removed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Remove an upstream server","tags":["servers"]},"patch":{"description":"Update specific fields of an existing upstream MCP server configuration.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.AddServerRequest"}}},"description":"Fields to update (all optional)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server updated successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - no fields or invalid body"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Partially update an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/config-to-secret":{"post":{"description":"Atomically reads the real value from the server config, stores it in the OS keyring, and rewrites the config field to ` + "`" + `${keyring:\u003cname\u003e}` + "`" + `. Unblocks the UI's Convert-to-secret affordance for values the API redacts on the read path.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret stored, config updated with reference"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad scope/key/secret_name, or value is already a reference / empty"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server or key not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver or config update failed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Convert a header / env value to a keyring secret","tags":["servers"]}},"/api/v1/servers/{id}/disable":{"post":{"description":"Disable a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server disabled successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disable an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/discover-tools":{"post":{"description":"Manually trigger tool discovery and indexing for a specific upstream MCP server. This forces an immediate refresh of the server's tool cache.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Tool discovery triggered successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot discover tools)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to discover tools"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Discover tools for a specific server","tags":["servers"]}},"/api/v1/servers/{id}/enable":{"post":{"description":"Enable a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server enabled successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/login":{"post":{"description":"Initiate OAuth authentication flow for a specific upstream MCP server. Returns structured OAuth start response with correlation ID for tracking.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.OAuthStartResponse"}}},"description":"OAuth login initiated successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.OAuthFlowError"}}},"description":"OAuth error (client_id required, DCR failed, etc.)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Trigger OAuth login for server","tags":["servers"]}},"/api/v1/servers/{id}/logout":{"post":{"description":"Clear OAuth authentication token and disconnect a specific upstream MCP server. The server will need to re-authenticate before tools can be used again.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"OAuth logout completed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled or read-only mode)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Clear OAuth token and disconnect server","tags":["servers"]}},"/api/v1/servers/{id}/logs":{"get":{"description":"Retrieve log entries for a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Number of log lines to retrieve","in":"query","name":"tail","schema":{"default":100,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerLogsResponse"}}},"description":"Server logs retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server logs","tags":["servers"]}},"/api/v1/servers/{id}/quarantine":{"post":{"description":"Place a specific upstream MCP server in quarantine to prevent tool execution","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server quarantined successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Quarantine a server","tags":["servers"]}},"/api/v1/servers/{id}/refresh":{"post":{"description":"Re-discover and re-index a specific upstream MCP server's tools without changing any security state. Alias of discover-tools, named for the upstream_servers 'refresh' operation; use it to make just-approved tools searchable immediately.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Tool refresh triggered successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot refresh)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to refresh tools"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Refresh a server's tools","tags":["servers"]}},"/api/v1/servers/{id}/restart":{"post":{"description":"Restart the connection to a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server restarted successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Restart an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/tool-calls":{"get":{"description":"Retrieves tool call history filtered by upstream server ID. Returns recent tool executions for the specified server including timestamps, arguments, results, and errors. Useful for server-specific debugging and monitoring.","parameters":[{"description":"Upstream server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Maximum number of records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerToolCallsResponse"}}},"description":"Server tool calls retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get server tool calls"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call history for specific server","tags":["tool-calls"]}},"/api/v1/servers/{id}/tools":{"get":{"description":"Retrieve all available tools for a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerToolsResponse"}}},"description":"Server tools retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/block":{"post":{"description":"Atomically approves AND disables the given tools (or all pending/changed tools when block_all=true) for a server. The approve and disable land in a single write per tool, so a tool is never left in the approved+enabled state. The \"blocked\" field counts tools actually blocked.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Block result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Block (approve+disable) tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/disable_all":{"post":{"description":"Bulk-toggles every known tool of a server. The \"changed\" field","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Operation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable or disable all tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/enable_all":{"post":{"description":"Bulk-toggles every known tool of a server. The \"changed\" field","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Operation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable or disable all tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/unquarantine":{"post":{"description":"Remove a specific upstream MCP server from quarantine to allow tool execution","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server unquarantined successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Unquarantine a server","tags":["servers"]}},"/api/v1/sessions":{"get":{"description":"Retrieves paginated list of active and recent MCP client sessions. Each session represents a connection from an MCP client to MCPProxy, tracking initialization time, tool calls, and connection status.","parameters":[{"description":"Maximum number of sessions to return (1-100, default 10)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Number of sessions to skip for pagination (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Filter by session status","in":"query","name":"status","schema":{"enum":["active","closed"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetSessionsResponse"}}},"description":"Sessions retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid status filter"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get sessions"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get active MCP sessions","tags":["sessions"]}},"/api/v1/sessions/{id}":{"get":{"description":"Retrieves detailed information about a specific MCP client session including initialization parameters, connection status, tool call count, and activity timestamps.","parameters":[{"description":"Session ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetSessionDetailResponse"}}},"description":"Session details retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Session ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Session not found"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get MCP session details by ID","tags":["sessions"]}},"/api/v1/stats/tokens":{"get":{"description":"Retrieve token savings statistics across all servers and sessions","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Token statistics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get token savings statistics","tags":["stats"]}},"/api/v1/status":{"get":{"description":"Get comprehensive server status including running state, listen address, upstream statistics, and timestamp","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server status information"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server status","tags":["status"]}},"/api/v1/telemetry/payload":{"get":{"description":"Render the exact JSON heartbeat payload that mcpproxy would next send to the telemetry endpoint, without making a network call. Counters in the payload reflect the current in-memory state. Spec 042.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Telemetry heartbeat payload"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Telemetry service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Preview next telemetry heartbeat payload","tags":["telemetry"]}},"/api/v1/tool-calls":{"get":{"description":"Retrieves paginated tool call history across all upstream servers or filtered by session ID. Includes execution timestamps, arguments, results, and error information for debugging and auditing.","parameters":[{"description":"Maximum number of records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Number of records to skip for pagination (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Filter tool calls by MCP session ID","in":"query","name":"session_id","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetToolCallsResponse"}}},"description":"Tool calls retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get tool calls"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call history","tags":["tool-calls"]}},"/api/v1/tool-calls/{id}":{"get":{"description":"Retrieves detailed information about a specific tool call execution including full request arguments, response data, execution time, and any errors encountered.","parameters":[{"description":"Tool call ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetToolCallDetailResponse"}}},"description":"Tool call details retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call not found"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call details by ID","tags":["tool-calls"]}},"/api/v1/tool-calls/{id}/replay":{"post":{"description":"Re-executes a previous tool call with optional modified arguments. Useful for debugging and testing tool behavior with different inputs. Creates a new tool call record linked to the original.","parameters":[{"description":"Original tool call ID to replay","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ReplayToolCallRequest"}}},"description":"Optional modified arguments for replay"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ReplayToolCallResponse"}}},"description":"Tool call replayed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call ID required or invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to replay tool call"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Replay a tool call","tags":["tool-calls"]}},"/api/v1/tools":{"get":{"description":"Consolidated, read-only listing of all tools from every configured server (including disabled servers and disabled/config-denied tools), enriched with approval state and 30-day usage. Backs the global Tools page and the CLI global ` + "`" + `tools list` + "`" + ` (spec 050, issue #437).","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GlobalToolsResponse"}}},"description":"All tools across all servers"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Could not enumerate servers"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List every tool across all servers","tags":["tools"]}},"/api/v1/tools/call":{"post":{"description":"Execute a tool on an upstream MCP server (wrapper around MCP tool calls)","requestBody":{"content":{"application/json":{"schema":{"properties":{"arguments":{"type":"object"},"tool_name":{"type":"string"}},"type":"object"}}},"description":"Tool call request with tool name and arguments","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Tool call result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (invalid payload or missing tool name)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error or tool execution failure"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Call a tool","tags":["tools"]}},"/healthz":{"get":{"description":"Get comprehensive health status including all component health (Kubernetes-compatible liveness probe)","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.HealthResponse"}}},"description":"Service is healthy"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.HealthResponse"}}},"description":"Service is unhealthy"}},"summary":"Get health status","tags":["health"]}},"/readyz":{"get":{"description":"Get readiness status including all component readiness checks (Kubernetes-compatible readiness probe)","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.ReadinessResponse"}}},"description":"Service is ready"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.ReadinessResponse"}}},"description":"Service is not ready"}},"summary":"Get readiness status","tags":["health"]}}},
+ "paths": {"/api/v1/activity":{"get":{"description":"Returns paginated list of activity records with optional filtering","parameters":[{"description":"Filter by activity type(s), comma-separated for multiple (Spec 024)","in":"query","name":"type","schema":{"enum":["tool_call","policy_decision","quarantine_change","server_change","system_start","system_stop","internal_tool_call","config_change"],"type":"string"}},{"description":"Filter by server name","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter by tool name","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter by MCP transport session ID","in":"query","name":"session_id","schema":{"type":"string"}},{"description":"Filter by work session (one client, one project, across reconnects)","in":"query","name":"work_session_id","schema":{"type":"string"}},{"description":"Filter by status","in":"query","name":"status","schema":{"enum":["success","error","blocked","rejected"],"type":"string"}},{"description":"Filter by intent operation type (Spec 018)","in":"query","name":"intent_type","schema":{"enum":["read","write","destructive"],"type":"string"}},{"description":"Filter by HTTP request ID for log correlation (Spec 021)","in":"query","name":"request_id","schema":{"type":"string"}},{"description":"Include successful call_tool_* internal tool calls (default: false, excluded to avoid duplicates)","in":"query","name":"include_call_tool","schema":{"type":"boolean"}},{"description":"Filter by sensitive data detection (true=has detections, false=no detections)","in":"query","name":"sensitive_data","schema":{"type":"boolean"}},{"description":"Filter by specific detection type (e.g., 'aws_access_key', 'credit_card')","in":"query","name":"detection_type","schema":{"type":"string"}},{"description":"Filter by severity level","in":"query","name":"severity","schema":{"enum":["critical","high","medium","low"],"type":"string"}},{"description":"Filter by agent token name (Spec 028)","in":"query","name":"agent","schema":{"type":"string"}},{"description":"Filter by auth type (Spec 028)","in":"query","name":"auth_type","schema":{"enum":["admin","agent"],"type":"string"}},{"description":"Filter activities after this time (RFC3339)","in":"query","name":"start_time","schema":{"type":"string"}},{"description":"Filter activities before this time (RFC3339)","in":"query","name":"end_time","schema":{"type":"string"}},{"description":"Maximum records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Pagination offset (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Omit arguments, response and metadata except a contextual whitelist (intent.reason, intent.operation_type, decision, reason, client_name, client_version) (default: false). For clients that render summary fields only; has_sensitive_data is still derived before metadata is dropped.","in":"query","name":"exclude_payloads","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"List activity records","tags":["Activity"]}},"/api/v1/activity/export":{"get":{"description":"Exports activity records in JSON Lines or CSV format for compliance","parameters":[{"description":"Export format: json (default) or csv","in":"query","name":"format","schema":{"type":"string"}},{"description":"Filter by activity type","in":"query","name":"type","schema":{"type":"string"}},{"description":"Filter by server name","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter by tool name","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter by MCP transport session ID","in":"query","name":"session_id","schema":{"type":"string"}},{"description":"Filter by work session (one client, one project, across reconnects)","in":"query","name":"work_session_id","schema":{"type":"string"}},{"description":"Filter by status","in":"query","name":"status","schema":{"type":"string"}},{"description":"Filter activities after this time (RFC3339)","in":"query","name":"start_time","schema":{"type":"string"}},{"description":"Filter activities before this time (RFC3339)","in":"query","name":"end_time","schema":{"type":"string"}},{"description":"Maximum records to export (1-50000, default 10000)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Pagination offset (default 0)","in":"query","name":"offset","schema":{"type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"type":"string"}},"application/x-ndjson":{"schema":{"type":"string"}},"text/csv":{"schema":{"type":"string"}}},"description":"Streamed activity records"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Export activity records","tags":["Activity"]}},"/api/v1/activity/summary":{"get":{"description":"Returns aggregated activity statistics for a time period","parameters":[{"description":"Time period: 1h, 24h (default), 7d, 30d","in":"query","name":"period","schema":{"type":"string"}},{"description":"Group by: server, tool (optional)","in":"query","name":"group_by","schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get activity summary statistics","tags":["Activity"]}},"/api/v1/activity/usage":{"get":{"description":"Returns the actor-owned usage aggregate (per-tool rollup + timeline + tokens-saved headline) for the Web UI usage graphs (Spec 069). Served from an in-memory snapshot — never a per-request full-log scan. Per-tool metrics are lifetime-cumulative; ` + "`" + `window` + "`" + ` scopes the timeline and filters the tool list to tools active within the span.","parameters":[{"description":"Time window for timeline + tool-list membership","in":"query","name":"window","schema":{"enum":["24h","7d","all"],"type":"string"}},{"description":"Filter to one server","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter to one tool","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter to tools with activity of this status","in":"query","name":"status","schema":{"enum":["success","error","blocked","rejected"],"type":"string"}},{"description":"Top-N tools by sort key; remainder folded into 'other' (default 20)","in":"query","name":"top","schema":{"type":"integer"}},{"description":"Ranking key for the per-tool list","in":"query","name":"sort","schema":{"enum":["calls","resp_bytes","error_rate","p95"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get usage statistics aggregate","tags":["Activity"]}},"/api/v1/activity/{id}":{"get":{"description":"Returns full details for a single activity record","parameters":[{"description":"Activity record ID (ULID)","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Not Found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get activity record details","tags":["Activity"]}},"/api/v1/annotations/coverage":{"get":{"description":"Reports how many upstream tools have MCP annotations vs don't, broken down by server","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Annotation coverage report"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get annotation coverage report","tags":["annotations"]}},"/api/v1/config":{"get":{"description":"Retrieves the current MCPProxy configuration including all server definitions, global settings, and runtime parameters","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetConfigResponse"}}},"description":"Configuration retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get current configuration","tags":["config"]},"patch":{"description":"Deep-merges only the fields present in the request body onto the live in-memory configuration and routes the result through the existing apply pipeline (validation, change detection, disk persistence, hot-reload). Fields the client omits — including masked secrets such as ` + "`" + `api_key` + "`" + ` and secret request headers — are preserved verbatim. Nested objects are merged recursively; arrays and scalars replace wholesale.","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}},"description":"Partial configuration with only the fields to change","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Configuration patch applied (inspect validation_errors for rejected values)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload or empty patch"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate configuration)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to read or apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Partially update configuration","tags":["config"]}},"/api/v1/config/apply":{"post":{"description":"Applies a new MCPProxy configuration. Validates and persists the configuration to disk. Some changes apply immediately, while others may require a restart. Returns detailed information about applied changes and restart requirements.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/config.Config"}}},"description":"Configuration to apply","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Configuration applied successfully with change details"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate configuration)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Apply configuration","tags":["config"]}},"/api/v1/config/docker-isolation":{"patch":{"description":"Convenience endpoint to flip ` + "`" + `docker_isolation.enabled` + "`" + ` without resending the full config. Persists to disk via the existing config writer — the file watcher then hot-reloads the change. Returns the new state and whether a restart is required for existing connections to pick it up.","requestBody":{"content":{"application/json":{"schema":{"properties":{"enabled":{"type":"boolean"}},"type":"object"}}},"description":"New isolation state","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Isolation toggle applied"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate configuration)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Toggle global Docker isolation","tags":["config"]}},"/api/v1/config/validate":{"post":{"description":"Validates a provided MCPProxy configuration without applying it. Checks for syntax errors, invalid server definitions, conflicting settings, and other configuration issues.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/config.Config"}}},"description":"Configuration to validate","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ValidateConfigResponse"}}},"description":"Configuration validation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Validation failed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Validate configuration","tags":["config"]}},"/api/v1/connect":{"get":{"description":"Returns the connection status for all known MCP client applications.\nEach entry indicates whether the client config file exists and whether\nMCPProxy is currently registered in it.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"List of ClientStatus objects"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List client connection status","tags":["connect"]}},"/api/v1/connect/{client}":{"delete":{"description":"Remove the MCPProxy entry from the specified client's configuration file.\nCreates a backup of the existing config before modifying.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectRequest"}}},"description":"Optional parameters (server_name)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client or entry not found"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disconnect MCPProxy from a client","tags":["connect"]},"get":{"description":"Resolves one client's status by reading its config file on demand.\nThis is the only Connect endpoint that opens a client config file, so\non macOS it is the sole place an App-Data privacy prompt may legitimately\nappear (scoped to this user action). Resolves access_state to\naccessible|absent|denied|malformed and populates remediation when denied.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ClientStatus"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get a single client's connection status (on-demand)","tags":["connect"]},"post":{"description":"Register MCPProxy as an MCP server in the specified client's configuration file.\nCreates a backup of the existing config before modifying.\nOptionally accepts precondition_token from a preview (Spec 091): when supplied,\nthe core rechecks the raw pre-write state and the entry it would write, and\nrefuses a drifted write with 409 before taking any backup. The 409 body's\naction discriminates the two conflict kinds: \"precondition_failed\" (stale\npreview — re-preview, do not retry) vs \"already_exists\" (entry present — pass\nforce=true). force=true never rescues a stale token.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectRequest"}}},"description":"Optional connection parameters (server_name, force, precondition_token)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectConflictResponse"}}},"description":"Conflict: action=already_exists (use force=true) or action=precondition_failed (preview is stale; re-preview)"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Connect MCPProxy to a client","tags":["connect"]}},"/api/v1/connect/{client}/preview":{"get":{"description":"Returns the exact entry a subsequent connect would add to the client's\nconfig — target path, server key, entry name, and entry contents — WITHOUT\nmodifying the file or creating a backup (Spec 078 US1). The embedded API key\nis masked in the payload; contains_api_key flags that a credential is written.\nentry_exists distinguishes a create from an overwrite of a same-named entry.\nReads the config on demand to classify create-vs-overwrite, so on macOS this\nmay raise an App-Data privacy prompt; a denial returns 403 + remediation.\nSpec 091 adds three fields: existing_entry_summary (present only when\nentry_exists — a sanitized, non-secret projection of the entry being replaced:\nits name, type, endpoint with query/userinfo stripped, command, and header and\nenv NAMES, never values); precondition_token (always present — an opaque keyed\ndigest of the raw pre-write state and the pending entry, echoed back on POST\nconnect to detect drift); and connect_refusal (present when the write would\nrefuse regardless of intent, e.g. a non-create-capable client with no config —\ntreat its presence as \"Connect unavailable\").","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}},{"description":"Entry name to preview (defaults to mcpproxy); mirror the value passed to POST connect","in":"query","name":"server_name","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectPreview"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Preview the change a connect would make (no write)","tags":["connect"]}},"/api/v1/connect/{client}/undo":{"post":{"description":"Reverts the connect that produced the named backup (Spec 078 US3):\nrestores the client config byte-for-byte from that backup, or — when\nbackup_name is empty because the connect created the file — deletes the\ncreated file. backup_name is the bare filename of the backup the connect\nreturned (never a path); undo resolves the full path server-side inside\nthe client's own config directory, so a client value cannot escape it.\nRefuses with 409 when the config changed since the connect (undo never\nclobbers later edits; use DELETE /connect/{client} for a surgical entry\nremoval instead). Takes its own safety backup first; its path is returned\nas backup_path in the result.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.UndoConnectRequest"}}},"description":"Undo parameters (server_name, backup_name = the bare filename of the backup the preceding connect returned)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult (action restored|deleted)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (e.g. backup_name is a path, or not a backup of this client's config)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client or backup no longer exists"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Config changed since connect; undo refused"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Undo a connect, restoring the pre-connect config","tags":["connect"]}},"/api/v1/diagnostics":{"get":{"description":"Get comprehensive health diagnostics including upstream errors, OAuth requirements, missing secrets, and Docker status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.Diagnostics"}}},"description":"Health diagnostics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get health diagnostics","tags":["diagnostics"]}},"/api/v1/docker/status":{"get":{"description":"Retrieve current Docker availability and recovery status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Docker status information"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get Docker status","tags":["docker"]}},"/api/v1/doctor":{"get":{"description":"Get comprehensive health diagnostics including upstream errors, OAuth requirements, missing secrets, and Docker status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.Diagnostics"}}},"description":"Health diagnostics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get health diagnostics","tags":["diagnostics"]}},"/api/v1/feedback":{"post":{"description":"Submit a bug report, feature request, or general feedback","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/telemetry.FeedbackRequest"}}},"description":"Feedback request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/telemetry.FeedbackResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Bad Request"},"429":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Too Many Requests"},"500":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyAuth":[]}],"summary":"Submit feedback","tags":["feedback"]}},"/api/v1/index/search":{"get":{"description":"Search across all upstream MCP server tools using BM25 keyword search","parameters":[{"description":"Search query","in":"query","name":"q","required":true,"schema":{"type":"string"}},{"description":"Maximum number of results","in":"query","name":"limit","schema":{"default":10,"maximum":100,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SearchToolsResponse"}}},"description":"Search results"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing query parameter)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Search for tools","tags":["tools"]}},"/api/v1/info":{"get":{"description":"Get essential server metadata including version, web UI URL, endpoint addresses, and update availability\nThis endpoint is designed for tray-core communication and version checking\nUse refresh=true query parameter to force an immediate update check against GitHub\nThe launched_by field reports durable launch provenance (\"tray\", \"installer\", or \"\" for user-launched/unknown)","parameters":[{"description":"Force immediate update check against GitHub","in":"query","name":"refresh","schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Server information with optional update info"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server information","tags":["status"]}},"/api/v1/onboarding/mark":{"post":{"description":"Updates wizard engagement and per-step status. Once engaged is\ntrue, the wizard does not auto-show again, even if state regresses.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.OnboardingMarkRequest"}}},"description":"Mark request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Updated OnboardingStateResponse"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Mark onboarding wizard state (Spec 046)","tags":["onboarding"]}},"/api/v1/onboarding/state":{"get":{"description":"Returns the wizard engagement record alongside live predicates\n(whether any client is connected, whether any server is configured),\nplus a derived ShouldShowWizard flag the frontend can rely on.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"OnboardingStateResponse"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get onboarding wizard state and predicates (Spec 046)","tags":["onboarding"]}},"/api/v1/profiles":{"get":{"description":"List all configured profiles with their effective servers and indexed tool count (Profiles v2). A profile scopes tool discovery and calls to a named subset of upstream servers.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Profile list"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Configuration unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List configured profiles","tags":["profiles"]}},"/api/v1/profiles/active":{"get":{"description":"Get the server-level default active profile used by UI surfaces (Web UI / tray). Empty string means \"all servers\". Note: within a live MCP session, the set_profile tool selection takes precedence over this default.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Active profile"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get the default active profile","tags":["profiles"]},"put":{"description":"Set the server-level default active profile for UI surfaces. The slug must match a configured profile; pass an empty string to clear. This does not affect live MCP sessions, which use the set_profile tool.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.SetActiveProfileRequest"}}},"description":"Profile slug to activate (empty clears)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Active profile updated"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid request body"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown profile"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Set the default active profile","tags":["profiles"]}},"/api/v1/registries":{"get":{"description":"Retrieves list of all MCP server registries that can be browsed for discovering and installing new upstream servers. Includes registry metadata, server counts, and API endpoints.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetRegistriesResponse"}}},"description":"Registries retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to list registries"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List available MCP server registries","tags":["registries"]},"post":{"description":"Adds a generic modelcontextprotocol/registry v0.1 https endpoint as a custom registry (MCP-866). The source is always tagged custom/unverified, so every server discovered through it lands quarantined and can never skip quarantine.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.AddRegistrySourceRequest"}}},"description":"Registry source (https url + optional protocol/id/name)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source added"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"invalid_registry_url"},"403":{"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/contracts.ErrorResponse"},{"$ref":"#/components/schemas/contracts.ErrorResponse"}]}}},"description":"Forbidden (agent tokens cannot mutate registries)"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin | duplicate_registry"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add a user-supplied registry source","tags":["registries"]}},"/api/v1/registries/{id}":{"delete":{"description":"Removes a custom/unverified registry previously added via add-source (MCP-1057). Built-in registries are refused with registry_shadows_builtin; an unknown id yields registry_not_found. The change is persisted copy-on-write.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source removed"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registries_locked"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Remove a user-added custom registry source","tags":["registries"]},"put":{"description":"Updates a custom registry previously added via add-source (MCP-1072): name, url, servers-url. Empty fields are left unchanged. Built-in registries are refused with registry_shadows_builtin; an unknown id yields registry_not_found; a non-https url yields invalid_registry_url. The change is persisted copy-on-write.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.EditRegistrySourceRequest"}}},"description":"Fields to update (name/url/servers_url; empty = unchanged)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source updated"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required | invalid_registry_url"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registries_locked"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Edit a user-added custom registry source","tags":["registries"]}},"/api/v1/registries/{id}/refresh":{"post":{"description":"Invalidates the cached server lists for a registry so the next search re-fetches fresh data from the source (spec 070 FR-007). Returns how many cache entries were dropped.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.RefreshRegistryResponse"}}},"description":"Registry cache refreshed"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to refresh registry cache"}},"summary":"Refresh a registry's cached server list","tags":["registries"]}},"/api/v1/registries/{id}/servers":{"get":{"description":"Searches for MCP servers within a specific registry by keyword or tag. Returns server metadata including installation commands, source code URLs, and npm package information for easy discovery and installation.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Search query keyword","in":"query","name":"q","schema":{"type":"string"}},{"description":"Filter by tag","in":"query","name":"tag","schema":{"type":"string"}},{"description":"Maximum number of results (default 10)","in":"query","name":"limit","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SearchRegistryServersResponse"}}},"description":"Servers retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to search servers"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Search MCP servers in a registry","tags":["registries"]}},"/api/v1/registries/{id}/servers/{serverId}/add":{"post":{"description":"Resolves a registry server reference server-side, re-derives a validated config, and persists it quarantined (spec 070 keystone). The client never sends a config blob — command/args/url and the quarantine flag are derived from the registry entry, not the request.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Server ID within the registry","in":"path","name":"serverId","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.AddFromRegistryRequest"}}},"description":"Optional overrides (name, env, enabled)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server added (quarantined)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"no_install_info | missing_required_input | duplicate_name"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot add servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found | server_not_found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add an upstream server from a registry reference","tags":["registries"]}},"/api/v1/routing":{"get":{"description":"Get the current routing mode and available MCP endpoints","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Routing mode information"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get routing mode information","tags":["status"]}},"/api/v1/secrets":{"post":{"description":"Stores a secret value in the operating system's secure keyring. The secret can then be referenced in configuration using ${keyring:secret-name} syntax. Automatically notifies runtime to restart affected servers.","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret stored successfully with reference syntax"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload, missing name/value, or unsupported type"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver not available or failed to store secret"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Store a secret in OS keyring","tags":["secrets"]}},"/api/v1/secrets/{name}":{"delete":{"description":"Deletes a secret from the operating system's secure keyring. Automatically notifies runtime to restart affected servers. Only keyring type is supported for security.","parameters":[{"description":"Name of the secret to delete","in":"path","name":"name","required":true,"schema":{"type":"string"}},{"description":"Secret type (only 'keyring' supported, defaults to 'keyring')","in":"query","name":"type","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret deleted successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Missing secret name or unsupported type"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver not available or failed to delete secret"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Delete a secret from OS keyring","tags":["secrets"]}},"/api/v1/servers":{"get":{"description":"Get a list of all configured upstream MCP servers with their connection status and statistics","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServersResponse"}}},"description":"Server list with statistics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List all upstream MCP servers","tags":["servers"]},"post":{"description":"Add a new MCP upstream server to the configuration. New servers are quarantined by default for security.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.AddServerRequest"}}},"description":"Server configuration","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server added successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid configuration"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Conflict - server with this name already exists"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add a new upstream server","tags":["servers"]}},"/api/v1/servers/disable_all":{"post":{"description":"Disable all configured upstream MCP servers with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk disable results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disable all servers","tags":["servers"]}},"/api/v1/servers/enable_all":{"post":{"description":"Enable all configured upstream MCP servers with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk enable results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable all servers","tags":["servers"]}},"/api/v1/servers/import":{"post":{"description":"Import MCP server configurations from a Claude Desktop, Claude Code, Cursor IDE, Codex CLI, or Gemini CLI configuration file","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}},{"description":"Force format (claude-desktop, claude-code, cursor, codex, gemini)","in":"query","name":"format","schema":{"type":"string"}},{"description":"Comma-separated list of server names to import","in":"query","name":"server_names","schema":{"type":"string"}}],"requestBody":{"content":{"multipart/form-data":{"schema":{"type":"file"}}},"description":"Configuration file to import","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid file or format"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from uploaded configuration file","tags":["servers"]}},"/api/v1/servers/import/json":{"post":{"description":"Import MCP server configurations from raw JSON or TOML content (useful for pasting configurations)","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportRequest"}}},"description":"Import request with content","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid content or format"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from JSON/TOML content","tags":["servers"]}},"/api/v1/servers/import/path":{"post":{"description":"Import MCP server configurations by reading a file from the server's filesystem","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportFromPathRequest"}}},"description":"Import request with file path","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid path or format"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"File not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from a file path","tags":["servers"]}},"/api/v1/servers/import/paths":{"get":{"description":"Returns well-known configuration file paths for supported formats with existence check","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.CanonicalConfigPathsResponse"}}},"description":"Canonical config paths"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get canonical config file paths","tags":["servers"]}},"/api/v1/servers/reconnect":{"post":{"description":"Force reconnection to all upstream MCP servers","parameters":[{"description":"Reason for reconnection","in":"query","name":"reason","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"All servers reconnected successfully"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Reconnect all servers","tags":["servers"]}},"/api/v1/servers/restart_all":{"post":{"description":"Restart all configured upstream MCP servers sequentially with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk restart results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Restart all servers","tags":["servers"]}},"/api/v1/servers/{id}":{"delete":{"description":"Remove an MCP upstream server from the configuration. This stops the server if running and removes it from config.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server removed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Remove an upstream server","tags":["servers"]},"patch":{"description":"Update specific fields of an existing upstream MCP server configuration.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.AddServerRequest"}}},"description":"Fields to update (all optional)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server updated successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - no fields or invalid body"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Partially update an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/config-to-secret":{"post":{"description":"Atomically reads the real value from the server config, stores it in the OS keyring, and rewrites the config field to ` + "`" + `${keyring:\u003cname\u003e}` + "`" + `. Unblocks the UI's Convert-to-secret affordance for values the API redacts on the read path.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret stored, config updated with reference"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad scope/key/secret_name, or value is already a reference / empty"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server or key not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver or config update failed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Convert a header / env value to a keyring secret","tags":["servers"]}},"/api/v1/servers/{id}/disable":{"post":{"description":"Disable a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server disabled successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disable an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/discover-tools":{"post":{"description":"Manually trigger tool discovery and indexing for a specific upstream MCP server. This forces an immediate refresh of the server's tool cache.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Tool discovery triggered successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot discover tools)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to discover tools"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Discover tools for a specific server","tags":["servers"]}},"/api/v1/servers/{id}/enable":{"post":{"description":"Enable a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server enabled successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/login":{"post":{"description":"Initiate OAuth authentication flow for a specific upstream MCP server. Returns structured OAuth start response with correlation ID for tracking.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.OAuthStartResponse"}}},"description":"OAuth login initiated successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.OAuthFlowError"}}},"description":"OAuth error (client_id required, DCR failed, etc.)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Trigger OAuth login for server","tags":["servers"]}},"/api/v1/servers/{id}/logout":{"post":{"description":"Clear OAuth authentication token and disconnect a specific upstream MCP server. The server will need to re-authenticate before tools can be used again.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"OAuth logout completed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled or read-only mode)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Clear OAuth token and disconnect server","tags":["servers"]}},"/api/v1/servers/{id}/logs":{"get":{"description":"Retrieve log entries for a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Number of log lines to retrieve","in":"query","name":"tail","schema":{"default":100,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerLogsResponse"}}},"description":"Server logs retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server logs","tags":["servers"]}},"/api/v1/servers/{id}/quarantine":{"post":{"description":"Place a specific upstream MCP server in quarantine to prevent tool execution","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server quarantined successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Quarantine a server","tags":["servers"]}},"/api/v1/servers/{id}/refresh":{"post":{"description":"Re-discover and re-index a specific upstream MCP server's tools without changing any security state. Alias of discover-tools, named for the upstream_servers 'refresh' operation; use it to make just-approved tools searchable immediately.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Tool refresh triggered successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot refresh)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to refresh tools"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Refresh a server's tools","tags":["servers"]}},"/api/v1/servers/{id}/restart":{"post":{"description":"Restart the connection to a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server restarted successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Restart an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/tool-calls":{"get":{"description":"Retrieves tool call history filtered by upstream server ID. Returns recent tool executions for the specified server including timestamps, arguments, results, and errors. Useful for server-specific debugging and monitoring.","parameters":[{"description":"Upstream server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Maximum number of records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerToolCallsResponse"}}},"description":"Server tool calls retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get server tool calls"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call history for specific server","tags":["tool-calls"]}},"/api/v1/servers/{id}/tools":{"get":{"description":"Retrieve all available tools for a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerToolsResponse"}}},"description":"Server tools retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/block":{"post":{"description":"Atomically approves AND disables the given tools (or all pending/changed tools when block_all=true) for a server. The approve and disable land in a single write per tool, so a tool is never left in the approved+enabled state. The \"blocked\" field counts tools actually blocked.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Block result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Block (approve+disable) tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/disable_all":{"post":{"description":"Bulk-toggles every known tool of a server. The \"changed\" field","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Operation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable or disable all tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/enable_all":{"post":{"description":"Bulk-toggles every known tool of a server. The \"changed\" field","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Operation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable or disable all tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/unquarantine":{"post":{"description":"Remove a specific upstream MCP server from quarantine to allow tool execution","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server unquarantined successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Unquarantine a server","tags":["servers"]}},"/api/v1/sessions":{"get":{"description":"Retrieves paginated list of active and recent MCP client sessions. Each session represents a connection from an MCP client to MCPProxy, tracking initialization time, tool calls, and connection status.","parameters":[{"description":"Maximum number of sessions to return (1-100, default 10)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Number of sessions to skip for pagination (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Filter by session status","in":"query","name":"status","schema":{"enum":["active","closed"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetSessionsResponse"}}},"description":"Sessions retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid status filter"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get sessions"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get active MCP sessions","tags":["sessions"]}},"/api/v1/sessions/{id}":{"get":{"description":"Retrieves detailed information about a specific MCP client session including initialization parameters, connection status, tool call count, and activity timestamps.","parameters":[{"description":"Session ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetSessionDetailResponse"}}},"description":"Session details retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Session ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Session not found"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get MCP session details by ID","tags":["sessions"]}},"/api/v1/stats/tokens":{"get":{"description":"Retrieve token savings statistics across all servers and sessions","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Token statistics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get token savings statistics","tags":["stats"]}},"/api/v1/status":{"get":{"description":"Get comprehensive server status including running state, listen address, upstream statistics, and timestamp","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server status information"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server status","tags":["status"]}},"/api/v1/telemetry/payload":{"get":{"description":"Render the exact JSON heartbeat payload that mcpproxy would next send to the telemetry endpoint, without making a network call. Counters in the payload reflect the current in-memory state. Spec 042.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Telemetry heartbeat payload"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Telemetry service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Preview next telemetry heartbeat payload","tags":["telemetry"]}},"/api/v1/tool-calls":{"get":{"description":"Retrieves paginated tool call history across all upstream servers or filtered by session ID. Includes execution timestamps, arguments, results, and error information for debugging and auditing.","parameters":[{"description":"Maximum number of records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Number of records to skip for pagination (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Filter tool calls by MCP session ID","in":"query","name":"session_id","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetToolCallsResponse"}}},"description":"Tool calls retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get tool calls"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call history","tags":["tool-calls"]}},"/api/v1/tool-calls/{id}":{"get":{"description":"Retrieves detailed information about a specific tool call execution including full request arguments, response data, execution time, and any errors encountered.","parameters":[{"description":"Tool call ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetToolCallDetailResponse"}}},"description":"Tool call details retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call not found"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call details by ID","tags":["tool-calls"]}},"/api/v1/tool-calls/{id}/replay":{"post":{"description":"Re-executes a previous tool call with optional modified arguments. Useful for debugging and testing tool behavior with different inputs. Creates a new tool call record linked to the original.","parameters":[{"description":"Original tool call ID to replay","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ReplayToolCallRequest"}}},"description":"Optional modified arguments for replay"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ReplayToolCallResponse"}}},"description":"Tool call replayed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call ID required or invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Shed by a concurrency limit (Retry-After header carries the wait hint)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to replay tool call"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Replay a tool call","tags":["tool-calls"]}},"/api/v1/tools":{"get":{"description":"Consolidated, read-only listing of all tools from every configured server (including disabled servers and disabled/config-denied tools), enriched with approval state and 30-day usage. Backs the global Tools page and the CLI global ` + "`" + `tools list` + "`" + ` (spec 050, issue #437).","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GlobalToolsResponse"}}},"description":"All tools across all servers"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Could not enumerate servers"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List every tool across all servers","tags":["tools"]}},"/api/v1/tools/call":{"post":{"description":"Execute a tool on an upstream MCP server (wrapper around MCP tool calls)","requestBody":{"content":{"application/json":{"schema":{"properties":{"arguments":{"type":"object"},"tool_name":{"type":"string"}},"type":"object"}}},"description":"Tool call request with tool name and arguments","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Tool call result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (invalid payload or missing tool name)"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Shed by a concurrency limit (Retry-After header carries the wait hint)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error or tool execution failure"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Call a tool","tags":["tools"]}},"/healthz":{"get":{"description":"Get comprehensive health status including all component health (Kubernetes-compatible liveness probe)","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.HealthResponse"}}},"description":"Service is healthy"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.HealthResponse"}}},"description":"Service is unhealthy"}},"summary":"Get health status","tags":["health"]}},"/readyz":{"get":{"description":"Get readiness status including all component readiness checks (Kubernetes-compatible readiness probe)","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.ReadinessResponse"}}},"description":"Service is ready"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.ReadinessResponse"}}},"description":"Service is not ready"}},"summary":"Get readiness status","tags":["health"]}}},
"openapi": "3.1.0"
}`
diff --git a/oas/swagger.yaml b/oas/swagger.yaml
index 08d51a5ec..d8709b4d7 100644
--- a/oas/swagger.yaml
+++ b/oas/swagger.yaml
@@ -1,5 +1,19 @@
components:
schemas:
+ config.ConcurrencyDefaults:
+ description: |-
+ ServerConcurrencyDefaults is scope (b) of FR-020: the blanket per-server
+ default set inherited by every server that does not override a setting.
+ Absent (the default) = no per-server limiting unless a server configures
+ it explicitly. File/API-configured only — no env scheme (FR-022).
+ properties:
+ max_concurrent_requests:
+ type: integer
+ queue_size:
+ type: integer
+ queue_timeout:
+ type: string
+ type: object
config.Config:
properties:
activity_cleanup_interval_min:
@@ -122,6 +136,19 @@ components:
type: string
logging:
$ref: '#/components/schemas/config.LogConfig'
+ max_concurrent_requests:
+ description: |-
+ Concurrency limits (spec 093, GH #955). Scope (a) of FR-020: the GLOBAL
+ AGGREGATE limiter — one proxy-wide cap on concurrently running upstream
+ tool calls, with its own bounded wait queue. Tri-state pointers: absent =
+ the limiter does not exist (default, zero behavior change); an explicit 0
+ max also disables it; positive = that cap. This scope is NEVER a
+ per-server inheritance source — per-server values come from
+ ServerConcurrencyDefaults / the per-server overrides — but a server's
+ effective concurrency is bounded by BOTH its own limiter and this one.
+ Resolved by ResolveGlobalConcurrency; hot-reloadable; overridable via
+ MCPPROXY_MAX_CONCURRENT_REQUESTS / _QUEUE_SIZE / _QUEUE_TIMEOUT (FR-022).
+ type: integer
max_result_size_chars:
description: Advertised on every tool as `_meta.anthropic/maxResultSizeChars`;
raises Claude Code's inline-response ceiling from 50k to up to 500k chars.
@@ -164,6 +191,10 @@ components:
explicit false to opt out of both. Per-server SkipQuarantine still
applies for the tool-level check on individual servers.
type: boolean
+ queue_size:
+ type: integer
+ queue_timeout:
+ type: string
read_only_mode:
type: boolean
registries:
@@ -215,6 +246,8 @@ components:
$ref: '#/components/schemas/config.SecurityConfig'
sensitive_data_detection:
$ref: '#/components/schemas/config.SensitiveDataDetectionConfig'
+ server_concurrency_defaults:
+ $ref: '#/components/schemas/config.ConcurrencyDefaults'
telemetry:
$ref: '#/components/schemas/config.TelemetryConfig'
tls:
@@ -892,6 +925,17 @@ components:
mcpproxy starts the process AND connects via network. Stdio servers ignore
this field. Zero or unset → 30s default.
type: string
+ max_concurrent_requests:
+ description: |-
+ Per-server concurrency overrides — scope (c) of FR-020 (spec 093, #955).
+ Tri-state per setting, exactly like HealthCheckInterval: absent = inherit
+ the per-server default set (server_concurrency_defaults), explicit 0 =
+ disable that setting for this server (0 max = no per-server limiter at
+ all; 0 queue_size = no pending capacity, shed immediately at the cap),
+ positive = override. The global aggregate limiter is never inherited from
+ here — it applies on top, so effective concurrency is min(per-server,
+ global). Resolved by Config.ResolveServerConcurrency.
+ type: integer
name:
type: string
oauth:
@@ -902,6 +946,10 @@ components:
quarantined:
description: Security quarantine status
type: boolean
+ queue_size:
+ type: integer
+ queue_timeout:
+ type: string
reconnect_on_use:
description: Attempt reconnection when a tool call targets a disconnected
server
@@ -1181,7 +1229,7 @@ components:
source:
$ref: '#/components/schemas/contracts.ActivitySource'
status:
- description: 'Result status: "success", "error", "blocked"'
+ description: 'Result status: "success", "error", "blocked", "rejected"'
type: string
timestamp:
description: When activity occurred
@@ -1216,6 +1264,13 @@ components:
period:
description: Time period (1h, 24h, 7d, 30d)
type: string
+ rejected_count:
+ description: |-
+ RejectedCount is the number of calls shed by a concurrency limiter before
+ they reached an upstream (spec 093). Counted separately from errors: it is
+ proxy backpressure, not an upstream fault, and it is the signal an
+ operator right-sizes max_concurrent_requests against.
+ type: integer
start_time:
description: Start of the period (RFC3339)
type: string
@@ -2209,6 +2264,16 @@ components:
type: string
last_retry_time:
type: string
+ max_concurrent_requests:
+ description: |-
+ Spec 093 (GH #955) — per-server concurrency overrides, scope (c) of
+ FR-020. Each setting is tri-state: nil (omitted) means "inherit
+ server_concurrency_defaults", 0 disables that setting for this server,
+ positive overrides it. Surfaced on the GET path so a caller can read back
+ what it set; PATCH/POST accept them via AddServerRequest. The effective
+ concurrency for a server is additionally bounded by the global aggregate
+ limiter, which is NOT an inheritance source for these fields.
+ type: integer
name:
type: string
oauth:
@@ -2222,6 +2287,10 @@ components:
$ref: '#/components/schemas/contracts.QuarantineStats'
quarantined:
type: boolean
+ queue_size:
+ type: integer
+ queue_timeout:
+ type: string
reconnect_count:
type: integer
reconnect_on_use:
@@ -2610,6 +2679,10 @@ components:
type: integer
p95_ms:
type: integer
+ rejected:
+ description: 'spec 093: shed by a concurrency limit; never executed, so
+ excluded from calls/latency'
+ type: integer
server:
type: string
sized_calls:
@@ -2681,12 +2754,26 @@ components:
type: string
isolation:
$ref: '#/components/schemas/httpapi.IsolationRequest'
+ max_concurrent_requests:
+ description: |-
+ MaxConcurrentRequests / QueueSize / QueueTimeout are the per-server
+ concurrency overrides (spec 093 / GH #955, FR-020 scope (c)). Each is
+ tri-state: a nil pointer means "leave unchanged" on PATCH and "inherit
+ server_concurrency_defaults" on create; an explicit 0 disables that
+ setting for this server; a positive value overrides it. Do NOT collapse
+ them to plain values — an omitted field would then silently reset a
+ configured limit.
+ type: integer
name:
type: string
protocol:
type: string
quarantined:
type: boolean
+ queue_size:
+ type: integer
+ queue_timeout:
+ type: string
reconnect_on_use:
type: boolean
trust_mode:
@@ -3134,6 +3221,7 @@ paths:
- success
- error
- blocked
+ - rejected
type: string
- description: Filter by intent operation type (Spec 018)
in: query
@@ -3512,6 +3600,7 @@ paths:
- success
- error
- blocked
+ - rejected
type: string
- description: Top-N tools by sort key; remainder folded into 'other' (default
20)
@@ -6459,6 +6548,13 @@ paths:
schema:
$ref: '#/components/schemas/contracts.ErrorResponse'
description: Method not allowed
+ "429":
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/contracts.ErrorResponse'
+ description: Shed by a concurrency limit (Retry-After header carries the
+ wait hint)
"500":
content:
application/json:
@@ -6525,6 +6621,13 @@ paths:
schema:
$ref: '#/components/schemas/contracts.ErrorResponse'
description: Bad request (invalid payload or missing tool name)
+ "429":
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/contracts.ErrorResponse'
+ description: Shed by a concurrency limit (Retry-After header carries the
+ wait hint)
"500":
content:
application/json:
diff --git a/specs/093-concurrency-limits/checklists/requirements.md b/specs/093-concurrency-limits/checklists/requirements.md
new file mode 100644
index 000000000..be0024542
--- /dev/null
+++ b/specs/093-concurrency-limits/checklists/requirements.md
@@ -0,0 +1,36 @@
+# Specification Quality Checklist: Request Queueing & Per-Upstream Concurrency Limits
+
+**Purpose**: Validate specification completeness and quality before proceeding to planning
+**Created**: 2026-08-07
+**Feature**: [spec.md](../spec.md)
+
+## Content Quality
+
+- [x] No implementation details (languages, frameworks, APIs)
+- [x] Focused on user value and business needs
+- [x] Written for non-technical stakeholders
+- [x] All mandatory sections completed
+
+## Requirement Completeness
+
+- [x] No [NEEDS CLARIFICATION] markers remain
+- [x] Requirements are testable and unambiguous
+- [x] Success criteria are measurable
+- [x] Success criteria are technology-agnostic (no implementation details)
+- [x] All acceptance scenarios are defined
+- [x] Edge cases are identified
+- [x] Scope is clearly bounded
+- [x] Dependencies and assumptions identified
+
+## Feature Readiness
+
+- [x] All functional requirements have clear acceptance criteria
+- [x] User scenarios cover primary flows
+- [x] Feature meets measurable outcomes defined in Success Criteria
+- [x] No implementation details leak into specification
+
+## Notes
+
+- Config key names (`max_concurrent_requests`, `queue_size`, `queue_timeout`) appear in requirements deliberately: they are user-facing configuration surface (the WHAT operators type), not implementation detail, and were part of the issue's own vocabulary.
+- The two-tier semaphore mechanism and choke-point placement live in the decision report and Assumptions (recorded Option-A decision), not in the requirements.
+- Open maintainer decisions (protocol-error convention, per-user fairness timing, health-status integration) are listed in the decision report; Assumptions record the v1 defaults so planning is unblocked.
diff --git a/specs/093-concurrency-limits/spec.md b/specs/093-concurrency-limits/spec.md
new file mode 100644
index 000000000..6f3e9be70
--- /dev/null
+++ b/specs/093-concurrency-limits/spec.md
@@ -0,0 +1,173 @@
+# Feature Specification: Request Queueing & Per-Upstream Concurrency Limits
+
+**Feature Branch**: `093-concurrency-limits`
+**Created**: 2026-08-07
+**Status**: Draft
+**Input**: User description: "Request queueing and per-upstream concurrency limits for multi-user deployments (fixes #955). Two-tier semaphore limiter (admission + run) at the managed-client choke point covering all dispatch paths including code_execution and activity replay. Config: max_concurrent_requests (global + per-server), queue_size, queue_timeout; 0=unlimited opt-in defaults; hot-reloadable. Shed semantics: isError tool result for MCP tools/call, HTTP 429 + Retry-After for REST, activity status rejected, rejection metrics. Decision report: docs/research/request-concurrency-issue-955-2026-08-07.html"
+
+> Related: issue #955 ("request queueing / concurrency limit for multi-user deployments"). Decision analysis with choke-point audit and option comparison: `docs/research/request-concurrency-issue-955-2026-08-07.html` (Option A chosen).
+
+## User Scenarios & Testing *(mandatory)*
+
+### User Story 1 - Cap concurrent load on a fragile upstream (Priority: P1)
+
+An operator runs mcpproxy as a shared service for multiple users/agents. One upstream MCP server is backed by a database that falls over under concurrent load. The operator sets a per-server concurrency limit; bursts of simultaneous tool calls to that server now run at most N at a time, with excess requests waiting briefly in a bounded queue instead of hammering the upstream — regardless of which user, agent, or internal feature (including sandboxed code execution) originated them.
+
+**Why this priority**: This is the core ask of #955 — protecting stateful upstreams from multi-user bursts without external infrastructure. Everything else refines it.
+
+**Independent Test**: Configure `max_concurrent_requests: 1, queue_size: 1` on a slow test upstream; fire 3 concurrent tool calls; observe exactly one running, one queued (then running), one rejected — and the upstream never sees 2 simultaneous requests.
+
+**Acceptance Scenarios**:
+
+1. **Given** a server with `max_concurrent_requests: 2`, **When** 5 tool calls arrive simultaneously for it, **Then** at most 2 execute against the upstream at any moment and the rest wait their turn in arrival order.
+2. **Given** the same limit, **When** calls originate from different surfaces (MCP tool-call variants, legacy call, REST tool-call endpoint, sandboxed code-execution scripts, activity replay), **Then** all of them count against and are bounded by the same per-server limit — no origin bypasses it.
+3. **Given** a stdio-based upstream with a limit, **When** concurrent calls arrive, **Then** the limit applies the same way it does for HTTP upstreams.
+4. **Given** no limits are configured (defaults), **When** any burst arrives, **Then** behavior is exactly as today — unlimited, no queueing, no new failure modes.
+
+---
+
+### User Story 2 - Bounded queue with predictable shedding (Priority: P2)
+
+When a limited server is saturated, excess requests wait in a bounded queue. If the queue is full, new requests are rejected immediately; if a queued request waits longer than the configured queue timeout, it is rejected then. In both cases the caller gets a clear, machine-actionable "server busy — retry" signal rather than a hang, a cryptic failure, or an aborted agent session.
+
+**Why this priority**: Queueing without bounds trades overload for unbounded latency and memory; shedding without clear semantics breaks agent loops. This story makes saturation safe and observable for callers.
+
+**Independent Test**: With `max_concurrent_requests: 1, queue_size: 1, queue_timeout: 2s` and a deliberately slow upstream, verify: 2nd call queues then runs; 3rd call is rejected instantly with a busy message; a queued call that waits > 2s is rejected with a timeout-flavored busy message; the AI agent making the calls sees a readable error it can retry, not a dropped connection.
+
+**Acceptance Scenarios**:
+
+1. **Given** the queue is full, **When** another call for that server arrives, **Then** it is rejected immediately (no waiting) with a message naming the server, its limit, and advising retry.
+2. **Given** a call is queued, **When** it waits longer than `queue_timeout`, **Then** it is rejected with a busy/timeout message; **When** the caller cancels while queued, **Then** the slot is released and the cancellation is reported as such (not as "server busy").
+3. **Given** an MCP agent client made the call, **When** it is shed, **Then** the rejection arrives as a normal tool-call error result (the kind agents read and retry), not a protocol/transport failure that can abort the session.
+4. **Given** the call came via the REST API, **When** it is shed, **Then** the response is HTTP 429 with a Retry-After hint.
+5. **Given** requests are being shed, **When** the operator inspects the activity log, **Then** shed calls are recorded with a distinct "rejected" status (distinguishable from upstream errors) including which limit triggered (queue full vs. queue timeout).
+
+---
+
+### User Story 3 - Global backstop and live tuning (Priority: P3)
+
+The operator also sets a global concurrency cap as a backstop for the whole proxy, and tunes any of the limits at runtime by editing the config file — changes apply to a running instance without a restart and without disrupting in-flight calls. Saturation is visible in metrics so the operator can right-size limits.
+
+**Why this priority**: Multi-user operators need a whole-instance guardrail and zero-downtime tuning; observability closes the loop. Valuable, but only after per-server limiting works.
+
+**Independent Test**: Set a global limit lower than the sum of per-server limits, verify aggregate concurrency never exceeds it; change limits in the config file while under load and verify the new values take effect without restart or dropped in-flight calls; confirm rejection counters and queue metrics move.
+
+**Acceptance Scenarios**:
+
+1. **Given** a global `max_concurrent_requests`, **When** load spans many servers, **Then** total concurrent upstream tool calls never exceed the global cap, and a slow server's queue does not consume global capacity while waiting.
+2. **Given** a running instance under load, **When** the operator changes limit values in the config file, **Then** new values govern all subsequent admissions without restart; running calls are never interrupted (they count against the new caps until they finish), and queued calls keep their original wait deadline but are admitted only under the new caps.
+3. **Given** limits are active, **When** the operator scrapes metrics, **Then** rejection counts (by server and reason) and queue depth are available; sustained saturation is visible.
+4. **Given** the server edition with multiple users, **When** several users call the same upstream, **Then** per-server limits bound the users' combined load on that upstream.
+
+---
+
+### Edge Cases
+
+- Caller cancels/disconnects while queued → the queue slot frees immediately; no leaked capacity.
+- Queue wait must not silently consume the existing per-call execution timeout — a call that queues 20s still gets its full execution time budget.
+- Limits change while requests are queued → existing waiters keep their original absolute deadlines but are admitted under the new caps (per FR-021's shared occupancy accounting); new arrivals see new rules; no double-counting or lost slots during the swap.
+- A server is disabled/quarantined/removed while calls are queued for it → queued calls fail with the existing server-unavailable semantics, not a hang until queue timeout.
+- Per-server limit larger than global → global still wins; documentation states effective concurrency is min of the two.
+- Per-server tri-state: absent inherits the per-server default set (not the global limiter); explicit 0 opts a server out of that setting while the global aggregate cap still applies; nonsensical configs (negative values, queue attached to a disabled limit) are rejected at validation with clear messages.
+- A queued call must never stall unrelated server management: admission happens outside any shared manager lock, and disabling/removing a server promptly fails its queued calls.
+- Internal traffic that is not upstream tool calls (local tool search, cached tool listings, lightweight health probes) is not throttled and cannot deadlock behind the limiter.
+- The standalone CLI debug client (separate process) is out of scope and documented as such.
+
+## Requirements *(mandatory)*
+
+### Functional Requirements
+
+**Limiting & queueing**
+
+- **FR-001**: Operators MUST be able to set a per-server maximum on concurrently executing upstream tool calls; excess calls wait in a bounded FIFO queue of configurable size (`max_concurrent_requests`, `queue_size` per server).
+- **FR-002**: Operators MUST be able to set a global maximum on concurrently executing upstream tool calls across all servers, layered with per-server limits such that waiting for a specific server's slot does not consume global capacity.
+- **FR-003**: Every in-process origin of upstream tool calls MUST be subject to the limits — the MCP tool-call variants, the legacy tool-call path, direct-routing mode, the REST tool-call endpoint, sandboxed code-execution scripts, and activity replay. No origin may bypass the limiter.
+- **FR-004**: A call arriving when the queue is full MUST be rejected immediately. A queued call MUST be rejected when its total wait exceeds the configured `queue_timeout` — a single absolute deadline spanning both the per-server and global admission steps combined (never `queue_timeout` per step). Queue order MUST be first-in-first-out.
+- **FR-005**: The call's execution timeout MUST begin only after admission (all limiter tiers acquired) so queue waiting never consumes execution budget; this applies to every origin, including internal ones whose execution deadline is currently created before the upstream call (activity replay MUST be adjusted accordingly). A caller's own deadline/cancellation is still honored while queued: cancellation MUST release the slot immediately and be reported as cancellation, not as shedding.
+- **FR-006**: With no limits configured (the default), observable behavior MUST be unchanged: no queueing, no limiting, no new errors, and performance overhead within the agreed regression threshold (see SC-002).
+- **FR-007**: Lightweight non-tool-call traffic (local tool search, coalesced tool listings, health probes) MUST NOT be throttled by these limits.
+- **FR-008**: A call MUST NOT wait in a limiter queue while holding any proxy-wide or manager-wide lock: the dispatch layer MUST resolve the target client and release shared locks before admission begins, so queued calls never block server add/remove/disable, reconciliation, or config reload.
+- **FR-009**: When a server is disabled, removed, or quarantined, its queued calls MUST be failed promptly with the existing server-unavailable semantics (not left to hit `queue_timeout`). Admission MUST atomically re-check the server's lifecycle state (a lifecycle token/tombstone honored by the limiter), so a call that snapshotted its client before the state change cannot enqueue after the change — no admit-after-disable race. A retired limiter instance MUST be drained (all outstanding holds released) before its capacity is considered gone; if a server with the same name is re-added while old holds are still draining, the new instance's capacity MUST NOT be double-counted against the drained one (fresh limiter, old holds release into the retired instance only).
+
+**Shed semantics**
+
+- **FR-010**: A shed MCP tool call MUST return a normal tool-call error result (readable by agent LLMs, retry-friendly) that states which limit triggered — the named server's limit or the proxy-wide (global) limit — and advises retrying shortly; never a transport/protocol failure that can abort an agent session. The message MUST NOT blame a server when the global limiter was the trigger.
+- **FR-011**: A shed REST tool call MUST return HTTP 429 with a Retry-After hint. The limiter's typed rejection identity MUST be preserved end-to-end through the REST dispatch path (today intermediate layers flatten errors into strings, which would make 429 mapping impossible); Retry-After derives from the effective `queue_timeout` of the scope that shed the call. Queues are always bounded (there is no unbounded-queue mode; `queue_size: 0` means no pending capacity).
+- **FR-012**: Every shed call MUST be recorded with a dedicated "rejected" activity status regardless of origin — including origins that bypass the MCP dispatch layer (sandboxed code execution, activity replay) — via an origin-independent rejection seam at or below the limiter, carrying stable metadata: `reason` (queue_full | queue_timeout) and `scope` (server | global) plus the server name and origin. Because activity status is a closed vocabulary today, the new status MUST be propagated through the full consumer contract: storage/API schema, activity filters and summaries, usage aggregation, exports, and Web UI rendering.
+- **FR-013**: Rejection counters (per server, per reason, per scope) and queue depth MUST be exposed through the existing metrics surface, again independent of call origin.
+
+**Configuration & operations**
+
+- **FR-020**: The config surface MUST define three distinct, separately named scopes, each carrying all three settings (`max_concurrent_requests`, `queue_size`, `queue_timeout`) with uniform semantics:
+ (a) the **global aggregate limiter** — one proxy-wide limiter with its own three values; `max_concurrent_requests: 0` (the default) disables it;
+ (b) an optional **per-server default set** — blanket values inherited by every server that does not override them; absent = no per-server limiting by default;
+ (c) **per-server overrides** — tri-state for each setting independently: absent = inherit the default set; explicit 0 = disable that setting for this server (0 for the limit = no per-server limiter; 0 for queue_size = no pending capacity, shed immediately at the cap); positive = override.
+ The global aggregate limiter is never a per-server inheritance source. Documentation MUST state that a server's effective concurrency is bounded by both its resolved per-server limit and the global limiter.
+- **FR-021**: All limit settings MUST be hot-reloadable without restart, published as one atomic generation (calls never observe a mix of new and old values across scopes). **Occupancy accounting is shared across generations**: running calls are never interrupted but continue to count against the new caps — after lowering a cap, no new admissions occur until occupancy drains below it (transient over-cap from grandfathered running calls is permitted and ends when they complete); already-queued calls keep their original absolute queue deadline but are admitted only under the new caps. Raising a cap admits eligible queued calls immediately. This is what "takes effect within one reload cycle" (SC-004) means.
+- **FR-022**: The global aggregate limiter's settings MUST be overridable via environment variables following the existing naming convention, documented, and reflected in the API schema like any other config field. The per-server default set and per-server overrides are file/API-configured only (no per-server env scheme exists or is introduced).
+- **FR-023**: Validation MUST reject, per scope after resolution: negative values, and a positive queue size whose corresponding concurrency limit resolves to disabled/unlimited — with actionable error messages naming the offending scope (global, defaults, or server name) and field.
+- **FR-024**: Limits MUST apply identically in the personal and server editions; in the server edition, per-server limits bound the combined load of all users on that upstream.
+
+### Key Entities
+
+- **Limit scope**: one of three separately configured scopes — global aggregate limiter, per-server default set, per-server override — each carrying max_concurrent_requests / queue_size / queue_timeout; per-server values are tri-state per setting (absent = inherit defaults, 0 = disabled, positive = value); global 0 = no global limiter.
+- **Wait queue**: bounded FIFO of admitted-but-not-yet-running calls for a given limit; characterized by size and per-call wait deadline.
+- **Shed event**: a rejected call — reason (queue_full | queue_timeout), scope (server | global), origin surface, server, timestamp; appears in the activity log and metrics regardless of origin.
+- **Limit generation**: the atomically published snapshot of all limit values (all three scopes) governing admissions; hot reload replaces the generation as a unit while occupancy accounting is shared across generations.
+- **Effective limit resolution**: per-server explicit value (0 = disabled, positive = cap) → absent inherits the per-server default set → no per-server limiting; effective concurrency for a server is additionally bounded by the global aggregate limiter.
+
+## Success Criteria *(mandatory)*
+
+### Measurable Outcomes
+
+- **SC-001**: With a per-server limit of N configured, the upstream never observes more than N simultaneous requests, under bursts of at least 10× N, from any mix of origins — verified for both stdio and HTTP upstreams.
+- **SC-002**: With defaults (no limits), the existing regression suites (unit, race, API E2E) pass unchanged, and tool-call latency overhead versus the previous release stays within an agreed threshold (≤1% median in the standard benchmark) — measured, not asserted.
+- **SC-003**: Under sustained 5× overload of a limited server, agent clients continue operating: 100% of shed calls surface as readable retryable errors; zero client sessions abort due to shedding.
+- **SC-004**: An operator can raise or lower any limit on a loaded instance and see it take effect within one config-reload cycle, with zero dropped in-flight calls.
+- **SC-005**: Every shed call is attributable in the activity log (status, reason, server) and counted in metrics; queue-full sheds respond in under 100 ms (no waiting on a full queue).
+- **SC-006**: The #955 deployment pattern (multi-user headless service in front of a database-backed stdio server) can cap that server's concurrency without any external reverse proxy, including traffic that never traverses an external listener.
+
+## Assumptions
+
+- The chosen approach is Option A from the decision report: a two-tier (admission + run) limiter at the single managed-client choke point, per-server acquired before global. The report's choke-point audit — including the finding that code-execution and replay paths bypass the manager layer — is the authoritative placement rationale.
+- Cross-model review (Codex, round 1) established two placement corrections the plan must honor: (1) the manager dispatch path currently holds a manager-wide read lock across the upstream call, so dispatch must snapshot the client and release shared locks before admission (FR-008); (2) activity replay currently creates its execution-timeout context before calling the client, so it must be restructured for FR-005 to hold.
+- Defaults ship as unlimited (0/0, 30s queue timeout when queueing is enabled): zero behavior change on upgrade. Documentation will recommend a small limit (e.g. 5) for stdio upstreams, mirroring common SDK worker-pool sizes; stdio does not require limit=1 since the transport legitimately multiplexes.
+- Per-user fairness within a server's queue (server edition) is out of scope for v1; the design keys limiters by server name so a future per-(user,server) extension (Spec 074 direction) does not require rework.
+- Upstream tool listings and health probes stay outside the limits (already coalesced / lightweight); a strict "count everything" mode is not offered in v1.
+- Shedding uses tool-result errors for MCP and 429 for REST; an additional JSON-RPC protocol-error convention (e.g. -32029) is deferred until client behavior is verified.
+- The separate-process CLI debug client is documented as out of scope.
+
+## Commit Message Conventions *(mandatory)*
+
+When committing changes for this feature, follow these guidelines:
+
+### Issue References
+- ✅ **Use**: `Related #955` - Links the commit to the issue without auto-closing
+- ❌ **Do NOT use**: `Fixes #955`, `Closes #955`, `Resolves #955` - These auto-close issues on merge
+
+**Rationale**: Issues should only be closed manually after verification and testing in production, not automatically on merge.
+
+### Co-Authorship
+- ❌ **Do NOT include**: `Co-Authored-By: Claude `
+- ❌ **Do NOT include**: "🤖 Generated with [Claude Code](https://claude.com/claude-code)"
+
+**Rationale**: Commit authorship should reflect the human contributors, not the AI tools used.
+
+### Example Commit Message
+```
+feat(upstream): per-server concurrency limits with bounded queueing
+
+Related #955
+
+Two-tier limiter at the managed-client choke point; opt-in via
+max_concurrent_requests/queue_size/queue_timeout (global + per-server).
+
+## Changes
+- internal/upstream/limiter package (admission + run semaphores)
+- Config fields with hot-reload, env overrides, validation
+- Shed semantics: isError tool result / 429 + Retry-After / activity "rejected"
+
+## Testing
+- Limiter unit tests incl. hot-swap under load
+- E2E: slow stdio server, queue + shed assertions, code_execution coverage
+```