From 1b14e315651778f4209ab782cf73ee6f3213eca3 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Fri, 7 Aug 2026 20:45:58 +0300 Subject: [PATCH 01/22] spec(093): request queueing + per-upstream concurrency limits Related #955 Spec for Option A from the decision research: two-tier limiter at the managed-client choke point; max_concurrent_requests/queue_size/ queue_timeout (global + per-server), 0=unlimited opt-in defaults, hot-reloadable; isError/429 shed semantics. Includes the decision report under docs/research/. --- ...uest-concurrency-issue-955-2026-08-07.html | 295 ++++++++++++++++++ .../checklists/requirements.md | 36 +++ specs/093-concurrency-limits/spec.md | 164 ++++++++++ 3 files changed, 495 insertions(+) create mode 100644 docs/research/request-concurrency-issue-955-2026-08-07.html create mode 100644 specs/093-concurrency-limits/checklists/requirements.md create mode 100644 specs/093-concurrency-limits/spec.md 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 00000000..51fdc805 --- /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).

+
+ +

01The 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 ─────────┘
+ + +

02Options

+
+ + + + + + + +
OptionCovers code_execution / replayEffortRisk
A · Two-tier semaphore in managed clientYes — only option that doesMLow-medium
B · Manager.CallTool + MCP dispatchNo — verified bypass holeSMedium
C · Inbound HTTP middleware onlyNo per-upstream limits at allSLow impl / high product
D · Bounded-queue dispatcher per upstreamYesLHigh
+ +
+
+

Option A — Two-tier semaphore limiter at the managed-client choke point

+
RECOMMENDEDEffort MRisk Low-medium
+
+

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 SRisk 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.
  • +
+
+
+ +
+

Option C — Inbound HTTP middleware only

+
Effort SHigh product risk
+

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 LRisk 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.
  • +
+
+
+ +

03Shed semantics

+
+ + + + + + + +
SurfaceOn queue full / timeoutWhy
MCP tools/callisError: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 APIHTTP 429 + Retry-Afternginx-documented API-correct status; Envoy/LiteLLM precedent.
Activity logNew status rejected (not generic error)Dashboards must separate saturation from upstream failure.
Metricsmcpproxy_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.

+ +

04Implementation plan

+
    +
  1. +

    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.
    • +
    +
  2. +
  3. +

    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.
    • +
    +
  4. +
  5. +

    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.
    • +
    +
  6. +
  7. +

    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.
    • +
    +
  8. +
  9. +

    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.
    • +
    +
  10. +
+ +

05Open decisions (maintainer input needed)

+
    +
  1. 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.
  2. +
  3. 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)?
  4. +
  5. 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)?
  6. +
  7. Activity vocabulary: new rejected status (touches Web UI filters, possibly telemetry schema) vs reusing error with a distinguishing code.
  8. +
  9. 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?
  10. +
  11. queue_timeout scope: per-server override for symmetry (as sketched) or global-only for a smaller config surface?
  12. +
  13. Health integration: saturation → degraded in v1 or follow-up?
  14. +
+ +

Method: multi-agent workflow (codebase choke-point audit + external prior-art research: Envoy, nginx, LiteLLM, MCP spec 2025-06-18, mcp-go v0.57.0 internals · 1.2M tokens). Companion report: macOS auto-updater for issue #957.

+
diff --git a/specs/093-concurrency-limits/checklists/requirements.md b/specs/093-concurrency-limits/checklists/requirements.md new file mode 100644 index 00000000..be002454 --- /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 00000000..1df574b3 --- /dev/null +++ b/specs/093-concurrency-limits/spec.md @@ -0,0 +1,164 @@ +# 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 apply to subsequent calls without restart; in-flight and already-queued calls complete under the rules they started with. +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 → old waiters drain under the old rules; 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. +- Zero/absent values mean unlimited; nonsensical configs (negative values, queue without a concurrency limit) are rejected at validation with clear messages. +- 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 wait exceeds the configured `queue_timeout`. Queue order MUST be first-in-first-out. +- **FR-005**: Queue waiting time MUST NOT count against the call's execution timeout; caller cancellation while queued MUST release the slot immediately and be reported as cancellation, not as shedding. +- **FR-006**: With no limits configured (all values zero/absent — the default), behavior MUST be byte-for-byte today's: no queueing, no limiting, no new errors. Limits are strictly opt-in. +- **FR-007**: Lightweight non-tool-call traffic (local tool search, coalesced tool listings, health probes) MUST NOT be throttled by these limits. + +**Shed semantics** + +- **FR-010**: A shed MCP tool call MUST return a normal tool-call error result (readable by agent LLMs, retry-friendly) that names the server, states it is at its concurrency limit, and advises retrying shortly — never a transport/protocol failure that can abort an agent session. +- **FR-011**: A shed REST tool call MUST return HTTP 429 with a Retry-After hint. +- **FR-012**: Shed calls MUST be recorded in the activity log with a dedicated "rejected" status, distinguishable from upstream errors, including the reason (queue full vs. queue timeout) and the server name. +- **FR-013**: Rejection counters (per server, per reason) and queue depth MUST be exposed through the existing metrics surface. + +**Configuration & operations** + +- **FR-020**: Limits MUST be configurable globally and per server in the standard config file, with per-server values overriding global defaults; a per-server value of zero/absent inherits the global setting; global zero means unlimited. +- **FR-021**: All limit settings MUST be hot-reloadable: config-file edits apply to a running instance without restart; in-flight and queued calls complete under the rules in force when they were admitted. +- **FR-022**: All limit settings MUST be overridable via environment variables following the existing naming convention, documented, and reflected in the API schema like any other config field. +- **FR-023**: Validation MUST reject negative values and a nonzero queue size without a concurrency limit, with actionable error messages. +- **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 + +- **Concurrency limit (per server / global)**: maximum simultaneously executing upstream tool calls; zero = unlimited. +- **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), origin surface, server, timestamp; appears in the activity log and metrics. +- **Effective limit resolution**: per-server override → global default → unlimited; effective concurrency for a server is bounded by both its own and the global limit. + +## 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), a full regression pass shows zero behavioral or performance change versus the previous release. +- **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. +- 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 +``` From 2c0faa9bd58d5e9e4a4e8f717570d5da2789d50d Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Fri, 7 Aug 2026 20:51:31 +0300 Subject: [PATCH 02/22] =?UTF-8?q?spec(093):=20codex=20round=201=20?= =?UTF-8?q?=E2=80=94=20lock-free=20admission,=20tri-state=20zeros,=20atomi?= =?UTF-8?q?c=20reload=20generation,=20origin-independent=20shed=20seam?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #955 - FR-008/009: never queue under manager locks; prompt-fail queued calls on disable/remove - FR-020: tri-state per-server semantics (absent=inherit, 0=none, positive=override) - FR-005: execution timeout starts after admission (replay restructure required) - FR-021: atomic limit generation for hot reload - FR-004: queue_timeout is one absolute deadline across both tiers - FR-011: typed rejection identity preserved through REST path - FR-012/013: origin-independent rejection seam + full activity-status consumer contract (schema, filters, summaries, aggregates, UI); reason+scope metadata - FR-010: global-limit sheds must not blame a server - FR-022: env overrides global-only - SC-002: measurable compatibility/benchmark criteria --- specs/093-concurrency-limits/spec.md | 37 ++++++++++++++++------------ 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/specs/093-concurrency-limits/spec.md b/specs/093-concurrency-limits/spec.md index 1df574b3..5807629b 100644 --- a/specs/093-concurrency-limits/spec.md +++ b/specs/093-concurrency-limits/spec.md @@ -68,7 +68,8 @@ The operator also sets a global concurrency cap as a backstop for the whole prox - Limits change while requests are queued → old waiters drain under the old rules; 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. -- Zero/absent values mean unlimited; nonsensical configs (negative values, queue without a concurrency limit) are rejected at validation with clear messages. +- Per-server tri-state: absent inherits the global default; explicit 0 opts a server out of per-server limiting while the global aggregate cap still applies; nonsensical configs (negative values, queue attached to an unlimited 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. @@ -81,39 +82,42 @@ The operator also sets a global concurrency cap as a backstop for the whole prox - **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 wait exceeds the configured `queue_timeout`. Queue order MUST be first-in-first-out. -- **FR-005**: Queue waiting time MUST NOT count against the call's execution timeout; caller cancellation while queued MUST release the slot immediately and be reported as cancellation, not as shedding. -- **FR-006**: With no limits configured (all values zero/absent — the default), behavior MUST be byte-for-byte today's: no queueing, no limiting, no new errors. Limits are strictly opt-in. +- **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`), and its limiter state MUST be released. **Shed semantics** -- **FR-010**: A shed MCP tool call MUST return a normal tool-call error result (readable by agent LLMs, retry-friendly) that names the server, states it is at its concurrency limit, and advises retrying shortly — never a transport/protocol failure that can abort an agent session. -- **FR-011**: A shed REST tool call MUST return HTTP 429 with a Retry-After hint. -- **FR-012**: Shed calls MUST be recorded in the activity log with a dedicated "rejected" status, distinguishable from upstream errors, including the reason (queue full vs. queue timeout) and the server name. -- **FR-013**: Rejection counters (per server, per reason) and queue depth MUST be exposed through the existing metrics surface. +- **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` (or a documented constant when unlimited queueing is configured). +- **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**: Limits MUST be configurable globally and per server in the standard config file, with per-server values overriding global defaults; a per-server value of zero/absent inherits the global setting; global zero means unlimited. -- **FR-021**: All limit settings MUST be hot-reloadable: config-file edits apply to a running instance without restart; in-flight and queued calls complete under the rules in force when they were admitted. -- **FR-022**: All limit settings MUST be overridable via environment variables following the existing naming convention, documented, and reflected in the API schema like any other config field. -- **FR-023**: Validation MUST reject negative values and a nonzero queue size without a concurrency limit, with actionable error messages. +- **FR-020**: Limits MUST be configurable globally and per server in the standard config file with explicit tri-state per-server semantics: **absent = inherit the global per-server default; explicit 0 = no per-server limit for this server (the global aggregate limiter still applies); positive = override**. Global `max_concurrent_requests: 0` (the default) means no global limiter. The global limiter's own queue settings and the inherited per-server defaults MUST be distinguishable in the config surface, and documentation MUST state that a server's effective concurrency is bounded by both its own and the global limit. +- **FR-021**: All limit settings MUST be hot-reloadable without restart. A reload MUST publish the global and per-server limit values as one atomic generation — a call is "admitted" under exactly one generation (captured at admission), and calls never observe a mix of new global and old per-server rules (or vice versa). In-flight and already-queued calls complete under the generation they captured. +- **FR-022**: The global limit settings MUST be overridable via environment variables following the existing naming convention, documented, and reflected in the API schema like any other config field. Per-server values are file/API-configured only (no per-server env scheme exists or is introduced). +- **FR-023**: Validation MUST reject negative values and a nonzero queue size whose corresponding concurrency limit resolves to unlimited, with actionable error messages naming the offending server 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 -- **Concurrency limit (per server / global)**: maximum simultaneously executing upstream tool calls; zero = unlimited. +- **Concurrency limit (per server / global)**: maximum simultaneously executing upstream tool calls; per-server values are tri-state (absent = inherit, 0 = no per-server limit, positive = override); 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), origin surface, server, timestamp; appears in the activity log and metrics. -- **Effective limit resolution**: per-server override → global default → unlimited; effective concurrency for a server is bounded by both its own and the global limit. +- **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 (global + per-server) a call is admitted under; hot reload replaces the generation as a unit. +- **Effective limit resolution**: per-server explicit value (0 = none, positive = cap) → absent inherits global default → unlimited; effective concurrency for a server is bounded by both its own and the global limit. ## 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), a full regression pass shows zero behavioral or performance change versus the previous release. +- **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). @@ -122,6 +126,7 @@ The operator also sets a global concurrency cap as a backstop for the whole prox ## 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. From 907b60bf60bd28d5de584de12444e0aa6e5e752f Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Fri, 7 Aug 2026 20:57:00 +0300 Subject: [PATCH 03/22] =?UTF-8?q?spec(093):=20codex=20round=202=20?= =?UTF-8?q?=E2=80=94=20shared=20occupancy=20across=20generations,=20admit-?= =?UTF-8?q?after-disable=20race,=20three-scope=20config=20model,=20no=20un?= =?UTF-8?q?bounded=20queue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #955 - FR-021: occupancy shared across generations (lowered caps block new admissions until drain; grandfathered over-cap transient and bounded) - FR-009: atomic lifecycle check at admission (tombstone) + limiter retirement after drain; re-add gets fresh capacity without double-count - FR-020: three separately named scopes (global aggregate / per-server default set / per-server overrides), tri-state per setting for all three fields - FR-011: removed 'unlimited queueing' clause; queues always bounded, queue_size 0 = no pending capacity --- specs/093-concurrency-limits/spec.md | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/specs/093-concurrency-limits/spec.md b/specs/093-concurrency-limits/spec.md index 5807629b..7a1b0ad9 100644 --- a/specs/093-concurrency-limits/spec.md +++ b/specs/093-concurrency-limits/spec.md @@ -55,7 +55,7 @@ The operator also sets a global concurrency cap as a backstop for the whole prox **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 apply to subsequent calls without restart; in-flight and already-queued calls complete under the rules they started with. +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. @@ -68,7 +68,7 @@ The operator also sets a global concurrency cap as a backstop for the whole prox - Limits change while requests are queued → old waiters drain under the old rules; 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 global default; explicit 0 opts a server out of per-server limiting while the global aggregate cap still applies; nonsensical configs (negative values, queue attached to an unlimited limit) are rejected at validation with clear messages. +- 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. @@ -87,30 +87,34 @@ The operator also sets a global concurrency cap as a backstop for the whole prox - **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`), and its limiter state MUST be released. +- **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` (or a documented constant when unlimited queueing is configured). +- **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**: Limits MUST be configurable globally and per server in the standard config file with explicit tri-state per-server semantics: **absent = inherit the global per-server default; explicit 0 = no per-server limit for this server (the global aggregate limiter still applies); positive = override**. Global `max_concurrent_requests: 0` (the default) means no global limiter. The global limiter's own queue settings and the inherited per-server defaults MUST be distinguishable in the config surface, and documentation MUST state that a server's effective concurrency is bounded by both its own and the global limit. -- **FR-021**: All limit settings MUST be hot-reloadable without restart. A reload MUST publish the global and per-server limit values as one atomic generation — a call is "admitted" under exactly one generation (captured at admission), and calls never observe a mix of new global and old per-server rules (or vice versa). In-flight and already-queued calls complete under the generation they captured. -- **FR-022**: The global limit settings MUST be overridable via environment variables following the existing naming convention, documented, and reflected in the API schema like any other config field. Per-server values are file/API-configured only (no per-server env scheme exists or is introduced). -- **FR-023**: Validation MUST reject negative values and a nonzero queue size whose corresponding concurrency limit resolves to unlimited, with actionable error messages naming the offending server and field. +- **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 -- **Concurrency limit (per server / global)**: maximum simultaneously executing upstream tool calls; per-server values are tri-state (absent = inherit, 0 = no per-server limit, positive = override); global 0 = no global limiter. +- **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 (global + per-server) a call is admitted under; hot reload replaces the generation as a unit. -- **Effective limit resolution**: per-server explicit value (0 = none, positive = cap) → absent inherits global default → unlimited; effective concurrency for a server is bounded by both its own and the global limit. +- **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)* From 485d620a22bd716f162b4b513c98b0b7f18be365 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Fri, 7 Aug 2026 20:58:34 +0300 Subject: [PATCH 04/22] =?UTF-8?q?spec(093):=20codex=20round=203=20?= =?UTF-8?q?=E2=80=94=20align=20queued-waiter=20edge=20case=20with=20FR-021?= =?UTF-8?q?=20shared=20occupancy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #955 --- specs/093-concurrency-limits/spec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specs/093-concurrency-limits/spec.md b/specs/093-concurrency-limits/spec.md index 7a1b0ad9..6f3e9be7 100644 --- a/specs/093-concurrency-limits/spec.md +++ b/specs/093-concurrency-limits/spec.md @@ -65,7 +65,7 @@ The operator also sets a global concurrency cap as a backstop for the whole prox - 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 → old waiters drain under the old rules; new arrivals see new rules; no double-counting or lost slots during the swap. +- 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. From b126c499da8de19cff1fef979513add8b5b2d31c Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sat, 8 Aug 2026 05:47:43 +0300 Subject: [PATCH 05/22] feat(limiter): bounded-concurrency admission limiter with shared occupancy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #955 New internal/upstream/limiter package: per-scope cap over a bounded FIFO wait queue, acquired with ONE absolute deadline shared across tiers (FR-004). Typed *LimitError carries scope/reason/server/limit/retry-after so the shed seams (MCP isError, REST 429, activity "rejected") can map it end-to-end. ## Changes - Limiter: FIFO waiter list over a resizable cap (not x/sync/semaphore — a semaphore's capacity is fixed, but FR-021 needs occupancy shared across generations on hot reload) - Registry: atomic per-server + global instances, Apply() publishes one generation, retirement tombstone fails queued calls promptly (FR-009) - nil limiter / zero max = passthrough; queue_size 0 = shed at the cap ## Testing - go test -race -count=5 ./internal/upstream/limiter/... — pass - covers FIFO order, instant queue-full reject, absolute-deadline timeout, caller-cancel vs shed, hot-swap lower/raise under load, retire races --- internal/upstream/limiter/errors.go | 90 ++++ internal/upstream/limiter/limiter.go | 306 +++++++++++++ internal/upstream/limiter/limiter_test.go | 484 +++++++++++++++++++++ internal/upstream/limiter/registry.go | 186 ++++++++ internal/upstream/limiter/registry_test.go | 284 ++++++++++++ 5 files changed, 1350 insertions(+) create mode 100644 internal/upstream/limiter/errors.go create mode 100644 internal/upstream/limiter/limiter.go create mode 100644 internal/upstream/limiter/limiter_test.go create mode 100644 internal/upstream/limiter/registry.go create mode 100644 internal/upstream/limiter/registry_test.go diff --git a/internal/upstream/limiter/errors.go b/internal/upstream/limiter/errors.go new file mode 100644 index 00000000..7b8b625a --- /dev/null +++ b/internal/upstream/limiter/errors.go @@ -0,0 +1,90 @@ +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) + } +} + +// 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 00000000..304c320b --- /dev/null +++ b/internal/upstream/limiter/limiter.go @@ -0,0 +1,306 @@ +// 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. +// +// 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" + "time" +) + +// Limits is one scope's configured concurrency settings, already resolved from +// the config tri-states. +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 + + mu sync.Mutex + limits Limits + running int + waiters *list.List // of *waiter, FIFO + retired bool +} + +// New builds a limiter for a scope. 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 { + if scope == ScopeGlobal { + server = "" + } + return &Limiter{ + scope: scope, + server: server, + limits: limits, + waiters: list.New(), + } +} + +// Limits returns the currently published limits for this scope. +func (l *Limiter) Limits() Limits { + if l == nil { + return Limits{} + } + l.mu.Lock() + defer l.mu.Unlock() + return l.limits +} + +// 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 publishes a new generation of limits for this scope (FR-021). +// Running calls are never interrupted but keep counting against the new cap, +// so lowering the cap admits nothing until occupancy drains; raising it admits +// eligible queued calls immediately. Queued calls keep their original absolute +// deadline. +func (l *Limiter) SetLimits(limits Limits) { + if l == nil { + return + } + l.mu.Lock() + l.limits = limits + 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, 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". +// +// 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 + } + + l.mu.Lock() + if l.retired { + l.mu.Unlock() + return nil, l.unavailableError() + } + // Fast path: unlimited scope, or free capacity. + if l.limits.Max <= 0 || l.running < l.limits.Max { + l.running++ + l.mu.Unlock() + return l.releaseFunc(), nil + } + // Saturated: is there pending capacity? + if l.waiters.Len() >= l.limits.QueueSize { + limits := l.limits + l.mu.Unlock() + return nil, l.limitError(ReasonQueueFull, limits) + } + w := &waiter{ch: make(chan struct{})} + w.el = l.waiters.PushBack(w) + limits := l.limits + 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. + l.mu.Lock() + retired := w.retired + 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.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 + } +} + +func (l *Limiter) limitError(reason Reason, limits Limits) *LimitError { + return &LimitError{ + Scope: l.scope, + Reason: reason, + Server: l.server, + Limit: limits.Max, + RetryAfter: limits.QueueTimeout, + } +} + +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. +func (l *Limiter) grantLocked() { + for l.waiters.Len() > 0 && (l.limits.Max <= 0 || l.running < l.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 00000000..f0c60292 --- /dev/null +++ b/internal/upstream/limiter/limiter_test.go @@ -0,0 +1,484 @@ +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) + } +} + +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) +} diff --git a/internal/upstream/limiter/registry.go b/internal/upstream/limiter/registry.go new file mode 100644 index 00000000..5cc39f84 --- /dev/null +++ b/internal/upstream/limiter/registry.go @@ -0,0 +1,186 @@ +package limiter + +import ( + "context" + "sync" + "sync/atomic" + "time" +) + +// Registry owns the live limiter instances: one per configured upstream server +// plus the proxy-wide aggregate. Readers take the current instance through an +// atomic pointer (no lock on the hot path); writers (config apply / hot reload) +// serialize on the registry mutex. +// +// Hot reload MUTATES the existing instance (SetLimits) instead of swapping in a +// new one, because occupancy must be shared across generations (FR-021). A new +// instance is created only for a server that has no live limiter — including a +// server re-added after retirement, whose old holds keep draining into the +// retired instance so capacity is never double-counted (FR-009). +type Registry struct { + mu sync.Mutex + global atomic.Pointer[Limiter] + servers sync.Map // string -> *atomic.Pointer[Limiter] +} + +// NewRegistry returns an empty registry: every scope is unconfigured, 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 is configured. +func (r *Registry) Global() *Limiter { + if r == nil { + return nil + } + return r.global.Load() +} + +// Server returns the limiter for an upstream, or nil when that server has no +// limiter (unconfigured, or retired). +func (r *Registry) Server(name string) *Limiter { + if r == nil { + return nil + } + v, ok := r.servers.Load(name) + if !ok { + return nil + } + ptr, _ := v.(*atomic.Pointer[Limiter]) + return ptr.Load() +} + +// SetGlobal publishes the global aggregate limiter's settings. An existing +// instance is updated in place so running calls keep counting; when no +// instance exists and the limits are disabled, nothing is allocated. +func (r *Registry) SetGlobal(limits Limits) { + r.mu.Lock() + defer r.mu.Unlock() + r.setGlobalLocked(limits) +} + +func (r *Registry) setGlobalLocked(limits Limits) { + if cur := r.global.Load(); cur != nil { + cur.SetLimits(limits) + return + } + if !limits.Enabled() { + return + } + r.global.Store(New(ScopeGlobal, "", limits)) +} + +// SetServer publishes one server's limits and returns the live instance (nil +// when the server has no instance and the limits are disabled). +func (r *Registry) SetServer(name string, limits Limits) *Limiter { + r.mu.Lock() + defer r.mu.Unlock() + return r.setServerLocked(name, limits) +} + +func (r *Registry) setServerLocked(name string, limits Limits) *Limiter { + v, _ := r.servers.LoadOrStore(name, &atomic.Pointer[Limiter]{}) + ptr, _ := v.(*atomic.Pointer[Limiter]) + if cur := ptr.Load(); cur != nil { + cur.SetLimits(limits) + return cur + } + if !limits.Enabled() { + return nil + } + fresh := New(ScopeServer, name, limits) + ptr.Store(fresh) + return fresh +} + +// RetireServer tombstones a server's limiter: queued calls fail immediately +// with ErrServerUnavailable and later admissions are refused, even if the +// caller snapshotted the instance before the state change (FR-009, +// admit-after-disable race). Outstanding holds drain into the retired +// instance. +func (r *Registry) RetireServer(name string) { + r.mu.Lock() + defer r.mu.Unlock() + r.retireServerLocked(name) +} + +func (r *Registry) retireServerLocked(name string) { + v, ok := r.servers.Load(name) + if !ok { + return + } + ptr, _ := v.(*atomic.Pointer[Limiter]) + if cur := ptr.Swap(nil); cur != nil { + cur.Retire() + } + r.servers.Delete(name) +} + +// 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. +func (r *Registry) Apply(global Limits, servers map[string]Limits) { + r.mu.Lock() + defer r.mu.Unlock() + + r.setGlobalLocked(global) + + var stale []string + r.servers.Range(func(key, _ any) bool { + name, _ := key.(string) + if _, ok := servers[name]; !ok { + stale = append(stale, name) + } + return true + }) + for _, name := range stale { + r.retireServerLocked(name) + } + for name, limits := range servers { + r.setServerLocked(name, limits) + } +} + +// Acquire admits a call through both tiers under ONE absolute queue deadline +// (FR-004). 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, queueDeadline time.Time) (func(), error) { + if r == nil { + return noopRelease, nil + } + + releaseServer, err := r.Server(server).Acquire(ctx, queueDeadline) + if err != nil { + return nil, err + } + releaseGlobal, err := r.Global().Acquire(ctx, queueDeadline) + 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). +func (r *Registry) ServerStats() map[string]Stats { + if r == nil { + return nil + } + out := make(map[string]Stats) + r.servers.Range(func(key, value any) bool { + name, _ := key.(string) + ptr, _ := value.(*atomic.Pointer[Limiter]) + if lim := ptr.Load(); lim != nil { + out[name] = lim.Stats() + } + return true + }) + return out +} diff --git a/internal/upstream/limiter/registry_test.go b/internal/upstream/limiter/registry_test.go new file mode 100644 index 00000000..4ba6e3f4 --- /dev/null +++ b/internal/upstream/limiter/registry_test.go @@ -0,0 +1,284 @@ +package limiter + +import ( + "context" + "errors" + "strings" + "sync" + "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", deadlineIn(time.Millisecond)) + 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) + } +} + +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", deadlineIn(time.Second)) + 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", deadlineIn(time.Second)) + if err != nil { + t.Fatalf("acquire a: %v", err) + } + defer relA() + + _, err = r.Acquire(context.Background(), "b", deadlineIn(time.Second)) + 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", 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) + 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() +} From e12ad09ae66a09ed49b4611d7eae52b3e0210589 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sat, 8 Aug 2026 05:59:53 +0300 Subject: [PATCH 06/22] feat(config): three-scope concurrency limit configuration surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #955 Adds the config surface for spec 093's request queueing: a global aggregate limiter, an optional per-server default set, and per-server overrides — three separately named scopes each carrying max_concurrent_requests / queue_size / queue_timeout with uniform tri-state semantics (FR-020). Everything is off by default, so an untouched config serializes unchanged. ## Changes - config: global fields + server_concurrency_defaults + per-server overrides; ResolveGlobalConcurrency / ResolveServerConcurrency / ResolveQueueBudget (one absolute deadline across tiers, FR-004) - validation per resolved scope, naming scope + field (FR-023); an explicit per-server max 0 stays a valid opt-out - DetectConfigChanges clauses for the global fields and the default set (per-server rides the Servers DeepEqual) - env overrides for the global scope only: MCPPROXY_MAX_CONCURRENT_REQUESTS, MCPPROXY_QUEUE_SIZE, MCPPROXY_QUEUE_TIMEOUT (FR-022) - merge/copy of the per-server tri-state pointers; make swagger - docs/configuration.md: new section + server fields + env table ## Testing - go test -race ./internal/config/... ./internal/runtime/... ./internal/upstream/... — pass - go test -tags server ./internal/serveredition/... -race — pass - golangci-lint (v2, .github/.golangci.yml) — 0 issues --- docs/configuration.md | 101 +++++ internal/config/concurrency.go | 236 +++++++++++ internal/config/concurrency_test.go | 461 ++++++++++++++++++++++ internal/config/config.go | 35 ++ internal/config/loader.go | 30 ++ internal/config/merge.go | 40 ++ internal/runtime/config_hotreload.go | 20 + internal/runtime/config_hotreload_test.go | 69 ++++ oas/docs.go | 2 +- oas/swagger.yaml | 48 +++ 10 files changed, 1041 insertions(+), 1 deletion(-) create mode 100644 internal/config/concurrency.go create mode 100644 internal/config/concurrency_test.go diff --git a/docs/configuration.md b/docs/configuration.md index 7f5ad535..47af3b22 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -180,6 +180,101 @@ 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. + ### Debug & Development ```json @@ -237,6 +332,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`) | @@ -1300,6 +1398,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/internal/config/concurrency.go b/internal/config/concurrency.go new file mode 100644 index 00000000..12aae50a --- /dev/null +++ b/internal/config/concurrency.go @@ -0,0 +1,236 @@ +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) +} + +// ResolveQueueBudget returns the total wait budget for a call to sc: the +// smallest positive queue_timeout among the ENABLED scopes. FR-004 requires one +// absolute deadline spanning the per-server and global admission steps +// combined, so taking the minimum keeps both scopes' contracts. Returns 0 when +// no limiter applies (nothing to wait for). +func (c *Config) ResolveQueueBudget(sc *ServerConfig) time.Duration { + if c == nil { + return 0 + } + budget := time.Duration(0) + consider := func(r ResolvedConcurrency) { + if !r.Enabled() || r.QueueTimeout <= 0 { + return + } + if budget == 0 || r.QueueTimeout < budget { + budget = r.QueueTimeout + } + } + consider(c.ResolveServerConcurrency(sc)) + consider(c.ResolveGlobalConcurrency()) + return budget +} + +// 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 00000000..dd7bcfea --- /dev/null +++ b/internal/config/concurrency_test.go @@ -0,0 +1,461 @@ +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) + } +} + +// TestResolveQueueBudget covers FR-004: one absolute deadline spans both tiers, +// so the budget is the smallest positive queue timeout among the ENABLED scopes. +func TestResolveQueueBudget(t *testing.T) { + cases := []struct { + name string + cfg Config + srv *ServerConfig + want time.Duration + }{ + {"no limits → no budget", Config{}, &ServerConfig{Name: "s"}, 0}, + { + "server only", + Config{ServerConcurrencyDefaults: &ConcurrencyDefaults{MaxConcurrentRequests: intPtr(2), QueueTimeout: durPtr(4 * time.Second)}}, + &ServerConfig{Name: "s"}, + 4 * time.Second, + }, + { + "global only", + Config{MaxConcurrentRequests: intPtr(2), QueueTimeout: durPtr(7 * time.Second)}, + &ServerConfig{Name: "s"}, + 7 * time.Second, + }, + { + "both enabled → smallest wins", + Config{ + MaxConcurrentRequests: intPtr(2), + QueueTimeout: durPtr(20 * time.Second), + ServerConcurrencyDefaults: &ConcurrencyDefaults{MaxConcurrentRequests: intPtr(2), QueueTimeout: durPtr(4 * time.Second)}, + }, + &ServerConfig{Name: "s"}, + 4 * time.Second, + }, + { + "disabled server scope does not contribute", + Config{ + MaxConcurrentRequests: intPtr(2), + QueueTimeout: durPtr(20 * time.Second), + ServerConcurrencyDefaults: &ConcurrencyDefaults{MaxConcurrentRequests: intPtr(2), QueueTimeout: durPtr(4 * time.Second)}, + }, + &ServerConfig{Name: "s", MaxConcurrentRequests: intPtr(0)}, + 20 * time.Second, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.cfg.ResolveQueueBudget(tc.srv); got != tc.want { + t.Fatalf("ResolveQueueBudget = %v, want %v", got, tc.want) + } + }) + } +} + +// 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 c551ebe3..51643472 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 04ec58c7..76696809 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 9115c5ff..d5e96a1d 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/runtime/config_hotreload.go b/internal/runtime/config_hotreload.go index 75b8fab9..4859c1a9 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 c5f200fd..f43582b8 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/oas/docs.go b/oas/docs.go index 73701fc2..6331158c 100644 --- a/oas/docs.go +++ b/oas/docs.go @@ -6,7 +6,7 @@ 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"},"listen_addr":{"description":"Listen address (e.g., \"127.0.0.1:8080\")","type":"string"},"update":{"$ref":"#/components/schemas/contracts.UpdateInfo"},"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.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\"","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"},"listen_addr":{"description":"Listen address (e.g., \"127.0.0.1:8080\")","type":"string"},"update":{"$ref":"#/components/schemas/contracts.UpdateInfo"},"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.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"}}}, "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","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"]}}}, diff --git a/oas/swagger.yaml b/oas/swagger.yaml index c0300059..4b63d5fc 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 From 556b020177a31646c794afea738a0d829285a353 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sat, 8 Aug 2026 06:12:10 +0300 Subject: [PATCH 07/22] feat(upstream): enforce concurrency limits at the managed-client choke point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #955 Admission control now runs inside managed.Client.CallTool, above coreClient.CallTool where the call_tool_timeout context is created — so queue waiting never consumes execution budget (FR-005) and every in-process dispatch path is bounded by the same limits (FR-003): call_tool_* variants, the REST tool-call endpoint, sandboxed code_execution and activity replay all funnel through it. ListTools and the health-check Ping are deliberately left unwrapped (FR-007). ## Changes - internal/upstream/limiter/observer.go: Rejection payload + Observer seam (origin-independent, FR-012/FR-013) and Registry.Active fast path - internal/upstream/managed/admission.go: per-server-then-global acquire under ONE absolute queue deadline resolved from ResolveQueueBudget - internal/upstream/concurrency.go: Manager owns the limiter registry; Apply publishes one generation on SetGlobalConfig (FR-021), add/update republishes one server, remove/disable/quarantine retires it (FR-009) - internal/upstream/manager.go: CRITICAL lock fix (FR-008) — CallTool snapshots the target client under m.mu.RLock and RELEASES it before reconnect-on-use, admission and the upstream call; a queued call can no longer stall AddServer/RemoveServer/config reload. Limiter rejections are returned verbatim so the typed identity survives (FR-011) ## Testing - upstream: queued-call-vs-server-management (FR-008), prompt fail on remove (FR-009), hot reload shares occupancy, disabled/quarantined own no limiter, zero-config allocates nothing, -race churn deadlock guard - managed: passthrough, instant queue-full shed, absolute-deadline timeout, global scope never names a server, caller cancel is not a shed, cap raise admits mid-queue, queue-budget hot reload --- internal/upstream/concurrency.go | 150 ++++++++++ internal/upstream/concurrency_test.go | 249 ++++++++++++++++ internal/upstream/limiter/observer.go | 51 ++++ internal/upstream/managed/admission.go | 113 ++++++++ internal/upstream/managed/admission_test.go | 268 ++++++++++++++++++ internal/upstream/managed/client.go | 17 ++ .../managed/global_config_hotreload_test.go | 35 +++ internal/upstream/manager.go | 82 +++++- 8 files changed, 952 insertions(+), 13 deletions(-) create mode 100644 internal/upstream/concurrency.go create mode 100644 internal/upstream/concurrency_test.go create mode 100644 internal/upstream/limiter/observer.go create mode 100644 internal/upstream/managed/admission.go create mode 100644 internal/upstream/managed/admission_test.go diff --git a/internal/upstream/concurrency.go b/internal/upstream/concurrency.go new file mode 100644 index 00000000..31d06225 --- /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 00000000..374e5d15 --- /dev/null +++ b/internal/upstream/concurrency_test.go @@ -0,0 +1,249 @@ +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_NoLimitersAllocated is the FR-006 guard: with no limits in the +// config, nothing is allocated and admission is a passthrough. +func TestZeroConfig_NoLimitersAllocated(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.Nil(t, m.Limiters().Global()) + assert.Nil(t, m.Limiters().Server("plain")) + assert.False(t, m.Limiters().Active("plain")) +} diff --git a/internal/upstream/limiter/observer.go b/internal/upstream/limiter/observer.go new file mode 100644 index 00000000..0b291736 --- /dev/null +++ b/internal/upstream/limiter/observer.go @@ -0,0 +1,51 @@ +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) + +// Active reports whether any limiter instance currently exists for this server +// or proxy-wide. It is the zero-config fast path (FR-006): with no limits +// configured the admission seam does no work at all beyond this check. +func (r *Registry) Active(server string) bool { + if r == nil { + return false + } + return r.Global() != nil || r.Server(server) != nil +} diff --git a/internal/upstream/managed/admission.go b/internal/upstream/managed/admission.go new file mode 100644 index 00000000..56a6e850 --- /dev/null +++ b/internal/upstream/managed/admission.go @@ -0,0 +1,113 @@ +package managed + +import ( + "context" + "errors" + "time" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" + "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 + } + if !adm.registry.Active(serverName) { + return noopRelease, nil + } + + // One absolute deadline for the whole admission: the smallest positive + // queue_timeout among the scopes that actually limit this call. + var deadline time.Time + globalCfg := mc.GetGlobalConfig() + if globalCfg == nil { + globalCfg = &config.Config{} + } + if budget := globalCfg.ResolveQueueBudget(serverCfg); budget > 0 { + deadline = time.Now().Add(budget) + } + + start := time.Now() + release, err := adm.registry.Acquire(ctx, serverName, deadline) + 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 00000000..d0f561e7 --- /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 21eb9ea5..68fe26f9 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 bd87d93c..f8f2d142 100644 --- a/internal/upstream/managed/global_config_hotreload_test.go +++ b/internal/upstream/managed/global_config_hotreload_test.go @@ -37,3 +37,38 @@ 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 a running client applies +// to the NEXT admission is re-resolved from the swapped global config, and it is +// always the smallest positive queue_timeout among the enabled scopes (FR-004, +// FR-021). +func TestSetGlobalConfig_QueueBudgetHotReload(t *testing.T) { + mc := newTestClientForHealth(t) + sc := &config.ServerConfig{ + Name: "flap-server", + Enabled: true, + MaxConcurrentRequests: intPtrAdm(2), + QueueTimeout: durPtrHC(20 * time.Second), + } + mc.SetConfig(sc) + + // Boot: only the per-server scope limits, so its timeout is the budget. + mc.SetGlobalConfig(&config.Config{Servers: []*config.ServerConfig{sc}}) + assert.Equal(t, 20*time.Second, mc.GetGlobalConfig().ResolveQueueBudget(mc.GetConfig())) + + // Operator adds a stricter global aggregate limiter: the shared absolute + // deadline must follow the smaller of the two. + mc.SetGlobalConfig(&config.Config{ + MaxConcurrentRequests: intPtrAdm(50), + QueueTimeout: durPtrHC(3 * time.Second), + Servers: []*config.ServerConfig{sc}, + }) + assert.Equal(t, 3*time.Second, mc.GetGlobalConfig().ResolveQueueBudget(mc.GetConfig())) + + // Disabling both limiters leaves nothing to wait for. + off := &config.ServerConfig{Name: "flap-server", Enabled: true, MaxConcurrentRequests: intPtrAdm(0)} + mc.SetConfig(off) + mc.SetGlobalConfig(&config.Config{Servers: []*config.ServerConfig{off}}) + assert.Equal(t, time.Duration(0), mc.GetGlobalConfig().ResolveQueueBudget(mc.GetConfig())) +} diff --git a/internal/upstream/manager.go b/internal/upstream/manager.go index 0652b7cf..cdce03ab 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() From b685c113b2d6b697e89fc3905fd054273dc486f9 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sat, 8 Aug 2026 06:47:40 +0300 Subject: [PATCH 08/22] =?UTF-8?q?feat(093):=20shed=20semantics=20=E2=80=94?= =?UTF-8?q?=20isError=20result,=20REST=20429,=20"rejected"=20activity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #955 A concurrency-limiter shed is now a first-class, attributable outcome on every surface instead of an anonymous upstream failure. ## Shed surfacing - MCP (call_tool_* variants, legacy handler, direct-routing mode): a retry-friendly isError tool RESULT, never a protocol error that could abort an agent session (FR-010). The wording lives on LimitError.UserMessage so the MCP text, the REST body and the log all read identically; the global-scope branch never names a server. - REST: the typed rejection survives CallToolDirect through a per-call capture box installed in the context — the IsError branch used to flatten it to a string, which made 429 mapping impossible (FR-011). internal/httpapi answers 429 with Retry-After derived from the shedding scope's effective queue_timeout (delta-seconds, rounded up). - Activity: new "rejected" status emitted from the limiter observer, BELOW the MCP dispatch layer, so code_execution and activity replay are covered by construction (FR-012). Metadata carries rejection_reason (queue_full|queue_timeout), rejection_scope (server|global), limit and retry_after_ms. Propagated through the storage vocabulary, activity summaries (rejected_count), usage aggregation (counted like blocked — never inflates calls, latency or the executed-call timeline), the type generator, api.ts, and the Web UI filter/badge/KPI. - Metrics: mcpproxy_tool_calls_rejected_total{server,reason,scope} plus mcpproxy_concurrency_active / _queue_depth{scope,server} gauges, sampled every 10s by the observability bridge (FR-013). ## Also - Activity replay no longer creates its CallToolTimeout context before dispatch: queue wait was being subtracted from the execution budget (FR-005). The execution timeout starts after admission, inside core.Client.CallTool. - Per-server concurrency fields now round-trip through UpstreamRecord — SaveConfiguration rebuilds the JSON server list from those records, so a REST/UI-set limit would otherwise be wiped on the next save. ## Testing - server: message wording per scope, isError shape, server-unavailable is not a shed, capture box, shedDispatchError unwrapping, and code_execution admission coverage through upstreamToolCaller - httpapi: 429 + Retry-After for both scopes, 500 for server-unavailable, delta-seconds rounding - runtime: rejected activity record + metadata, usage aggregate does not inflate calls, source mapping, AST guard that replay never re-introduces a pre-dispatch deadline --- cmd/generate-types/main.go | 22 +++ docs/configuration.md | 20 ++ frontend/src/components/ActivityWidget.vue | 4 +- frontend/src/types/api.ts | 16 +- frontend/src/types/contracts.ts | 17 ++ frontend/src/utils/activity.ts | 8 +- frontend/src/views/Activity.vue | 18 +- frontend/src/views/Usage.vue | 1 + internal/contracts/activity.go | 25 ++- internal/contracts/types.go | 1 + internal/httpapi/activity.go | 39 ++-- internal/httpapi/concurrency_shed.go | 26 +++ internal/httpapi/concurrency_shed_test.go | 140 ++++++++++++++ internal/httpapi/server.go | 16 ++ internal/observability/metrics.go | 68 +++++++ internal/runtime/activity_service.go | 53 ++++++ internal/runtime/concurrency_rejections.go | 65 +++++++ .../runtime/concurrency_rejections_test.go | 176 ++++++++++++++++++ internal/runtime/event_bus.go | 23 +++ internal/runtime/events.go | 6 + internal/runtime/runtime.go | 20 +- internal/runtime/usage_aggregate.go | 20 +- internal/server/concurrency_shed.go | 107 +++++++++++ internal/server/concurrency_shed_test.go | 168 +++++++++++++++++ internal/server/mcp.go | 55 ++++++ internal/server/mcp_routing.go | 8 + internal/server/observability_bridge.go | 43 +++++ internal/storage/activity_models.go | 45 ++++- internal/storage/async_ops.go | 3 + internal/storage/async_ops_test.go | 6 + internal/storage/manager.go | 9 + internal/storage/models.go | 8 + internal/upstream/limiter/errors.go | 33 ++++ oas/docs.go | 4 +- oas/swagger.yaml | 22 ++- 35 files changed, 1254 insertions(+), 41 deletions(-) create mode 100644 internal/httpapi/concurrency_shed.go create mode 100644 internal/httpapi/concurrency_shed_test.go create mode 100644 internal/runtime/concurrency_rejections.go create mode 100644 internal/runtime/concurrency_rejections_test.go create mode 100644 internal/server/concurrency_shed.go create mode 100644 internal/server/concurrency_shed_test.go diff --git a/cmd/generate-types/main.go b/cmd/generate-types/main.go index 8c78cd3c..2a2539e5 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 diff --git a/docs/configuration.md b/docs/configuration.md index 47af3b22..01e591d2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -275,6 +275,26 @@ Only the **global aggregate** scope has environment overrides `MCPPROXY_QUEUE_TIMEOUT`); the default set and per-server overrides are file/API-configured. +**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. The same sheds also appear +in the activity log (`status: rejected`), 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. + ### Debug & Development ```json diff --git a/frontend/src/components/ActivityWidget.vue b/frontend/src/components/ActivityWidget.vue index 0b469d54..11756f80 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 3f41539d..3c317b2b 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 28a69fcb..bde20965 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; diff --git a/frontend/src/utils/activity.ts b/frontend/src/utils/activity.ts index 4b4613a9..1f0e809d 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 d78eef93..c3c7754b 100644 --- a/frontend/src/views/Activity.vue +++ b/frontend/src/views/Activity.vue @@ -72,6 +72,16 @@
Blocked
{{ summary.blocked_count }}
+ @@ -146,6 +156,7 @@ + @@ -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 f4d99425..fe075f1b 100644 --- a/frontend/src/views/Usage.vue +++ b/frontend/src/views/Usage.vue @@ -20,6 +20,7 @@ +