docs/research/request-concurrency-issue-955-2026-08-07.html
MCPProxy · Engineering decision report
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).
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|destructivemcp.go:2112──┐
legacy call_toolmcp.go:2536──┼── Manager.CallTool ──┐
direct-routing modemcp\_routing.go:216──┘manager.go:1123│
REST /api/v1/tools/call → CallToolDirectmcp.go:5356──(same variants)──┤
├──▶managed.Client.CallToolcode_executionmcp\_code\_execution.go:453-464──── GetClient ─────────┤client.go:640 — limiter hereactivity replayruntime.go:1218-1228──── GetClient ─────────┘
sseRequestMu for SSE transports. Stdio upstreams are fully multiplexed by mcp-go v0.57.0 (frame write under stdinMu, responses matched by JSON-RPC id) — so queued goroutines block naturally before the frame write, and a semaphore wrapping CallTool works for stdio too. core/client.go:70,396-405 · mcp-go stdio.go:423-489call_tool_timeout: that 2-minute context is created deeper, inside core.Client.CallTool — acquiring before delegation keeps queue time separate. core/client.go:430-445retrieve_tools (local Bleve, no upstream traffic), ListTools (already leader/follower-coalesced), health-check Ping (5s lightweight probe), and the separate-process CLI debug client. managed/client.go:504-637, 971-985 · cli/client.go:266GetConfig/SetConfig; global config fans out via Manager.SetGlobalConfig. Caveat: semaphore.Weighted can’t resize, so reload means an atomic limiter swap with release-closures bound to the old instance. manager.go:298-314| Option | Covers code_execution / replay | Effort | Risk |
|---|---|---|---|
| A · Two-tier semaphore in managed client | Yes — only option that does | M | Low-medium |
| B · Manager.CallTool + MCP dispatch | No — verified bypass hole | S | Medium |
| C · Inbound HTTP middleware only | No per-upstream limits at all | S | Low impl / high product |
| D · Bounded-queue dispatcher per upstream | Yes | L | High |
New internal/upstream/limiter package on x/sync/semaphore. Each limiter = two weighted semaphores: an admission semaphore sized max_concurrent + queue_size acquired with TryAcquire (failure = queue full → instant shed, Envoy max_pending_requests semantics), and a run semaphore sized max_concurrent acquired ctx-aware under queue_timeout. One limiter per server (registry keyed by name) + one global; acquired per-server-first inside managed.Client.CallTool.
semaphore.Weighted gives FIFO fairness, cancellable waits, and queue_timeout free.Manager.CallTool RLock hazard (blocking there stalls AddServer/RemoveServer writers).Global limit in the mcp.go call_tool dispatch; per-server limit in Manager.CallTool. No managed-client changes.
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.A chi middleware capping concurrent inbound requests on /mcp and /api/v1, returning 429 when saturated. No per-upstream awareness.
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).
| Surface | On queue full / timeout | Why |
|---|---|---|
MCP tools/call | isError:true tool result: “server '<name>' is at its concurrency limit (<n> running, <q> queued). Retry in a few seconds.” | The MCP spec’s own canonical isError example is a rate-limit message; agent LLMs read it and back off. A JSON-RPC protocol error can abort some client loops. |
| REST API | HTTP 429 + Retry-After | nginx-documented API-correct status; Envoy/LiteLLM precedent. |
| Activity log | New status rejected (not generic error) | Dashboards must separate saturation from upstream failure. |
| Metrics | `mcpproxy_tool_calls_rejected_total{server, reason="queue_full" | "queue_timeout"}` + queue-depth gauge |
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.
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.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.DetectConfigChanges clause for the three global fields (config_hotreload.go:102-104 model) — per-server fields are already covered by the Servers DeepEqual.MCPPROXY_MAX_CONCURRENT_REQUESTS / _QUEUE_SIZE / _QUEUE_TIMEOUT (loader.go:638-690); then make swagger (CI verifies) and docs/configuration.md.defer release() both; delegate to coreClient. Do not wrap ListTools or Ping. Reconnect-on-use runs before CallTool, so it never holds a slot.ErrQueueFull/ErrQueueTimeout via errors.Is in mcp.go (isError tool result, early-error pattern at :2074-2076) and httpapi (429 + Retry-After).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.go test -race, -tags server, ./scripts/test-api-e2e.sh.-32029 JSON-RPC error with data.retryAfter for non-tool methods (emerging convention; unverified how Claude Code/Cursor react mid-loop)?tools/list and Ping ever count against a strict per-upstream limit for fragile single-threaded stdio servers (sketch excludes them)?rejected status (touches Web UI filters, possibly telemetry schema) vs reusing error with a distinguishing code.degraded in v1 or follow-up?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.