feat(mcp): bound outbound tool-call concurrency per MCP server - #31641
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
002a63e to
73dec1b
Compare
Greptile SummaryThis PR adds an optional outbound concurrency cap for MCP server tool calls. The main changes are:
Confidence Score: 5/5The change is narrowly scoped and covered by targeted tests for capped, uncapped, non-positive, per-server, and OpenAPI-backed MCP tool-call behavior. The implementation consistently threads the new field through schemas, models, persistence, and runtime enforcement while preserving existing unbounded behavior when unset or non-positive.
What T-Rex did
Reviews (5): Last reviewed commit: "feat(mcp): bound outbound tool-call conc..." | Re-trigger Greptile |
73dec1b to
634f254
Compare
PR overviewAll previously flagged issues have been addressed. No open security concerns remain on this pull request. Security reviewNo open security issues remain on this pull request. Fixed/addressed: 1 · PR risk: 0/10 |
Add an optional per-server max_concurrent_requests that caps how many tool calls LiteLLM sends to one MCP server at once, so batch-processing backends are not overwhelmed by unbounded parallel dispatch. Excess calls queue on a per-server asyncio.Semaphore instead of being rejected. Unset or non-positive means unlimited, preserving existing behavior. Resolves LIT-2749
634f254 to
0dbb48c
Compare
|
Good catch on the OpenAPI path. Fixed in the latest commit: the limiter now wraps both dispatch branches in |
mateo-berri
left a comment
There was a problem hiding this comment.
We gotta be careful to bump proxy extras for this migration. Otherwise LGTM, thanks!
…t forms The proxy has enforced a per-server outbound tool-call concurrency cap (max_concurrent_requests) across every MCP egress path since #31641, and the management API has accepted the field on create and update all along, but the dashboard offered no way to set it. Add an optional Max Concurrent Requests input to the MCP server create and edit forms; it applies to every auth type and transport, so it renders unconditionally rather than gated on auth mode. Clearing the field on edit sends null so the stored limit is unset. Also rebuild the per-server semaphore when the configured limit changes. Previously the semaphore was created once per server_id and never resized, so an edited limit only took effect after a proxy restart even though the new value was persisted and reloaded into the registry.
…t forms (#32397) * feat(ui): expose MCP max_concurrent_requests in server create and edit forms The proxy has enforced a per-server outbound tool-call concurrency cap (max_concurrent_requests) across every MCP egress path since #31641, and the management API has accepted the field on create and update all along, but the dashboard offered no way to set it. Add an optional Max Concurrent Requests input to the MCP server create and edit forms; it applies to every auth type and transport, so it renders unconditionally rather than gated on auth mode. Clearing the field on edit sends null so the stored limit is unset. Also rebuild the per-server semaphore when the configured limit changes. Previously the semaphore was created once per server_id and never resized, so an edited limit only took effect after a proxy restart even though the new value was persisted and reloaded into the registry. * feat(ui): mark MCP max concurrent requests field label as optional * test(ui): stop OBO create-form tests from timing out on CI The token-exchange payload test and the Entra scope-required test filled five text fields with user.type, which dispatches a full keystroke sequence per character; every input event runs the antd form onValuesChange handler and re-renders the whole CreateMCPServer tree, roughly 120 renders per test. As the form grew the two tests reached 8s and 18s locally, which crosses the 30s vitest timeout on slower CI containers; ui_unit_tests failed twice this way. Switch the plain text fields to fireEvent.change (one input event per field), matching the existing stdio test pattern. Both tests assert form output, not keystroke behavior, and now run in about 3s each.
…t forms (BerriAI#32397) * feat(ui): expose MCP max_concurrent_requests in server create and edit forms The proxy has enforced a per-server outbound tool-call concurrency cap (max_concurrent_requests) across every MCP egress path since BerriAI#31641, and the management API has accepted the field on create and update all along, but the dashboard offered no way to set it. Add an optional Max Concurrent Requests input to the MCP server create and edit forms; it applies to every auth type and transport, so it renders unconditionally rather than gated on auth mode. Clearing the field on edit sends null so the stored limit is unset. Also rebuild the per-server semaphore when the configured limit changes. Previously the semaphore was created once per server_id and never resized, so an edited limit only took effect after a proxy restart even though the new value was persisted and reloaded into the registry. * feat(ui): mark MCP max concurrent requests field label as optional * test(ui): stop OBO create-form tests from timing out on CI The token-exchange payload test and the Entra scope-required test filled five text fields with user.type, which dispatches a full keystroke sequence per character; every input event runs the antd form onValuesChange handler and re-renders the whole CreateMCPServer tree, roughly 120 renders per test. As the form grew the two tests reached 8s and 18s locally, which crosses the 30s vitest timeout on slower CI containers; ui_unit_tests failed twice this way. Switch the plain text fields to fireEvent.change (one input event per field), matching the existing stdio test pattern. Both tests assert form output, not keystroke behavior, and now run in about 3s each.
Relevant issues
max_concurrent_requestscap that queues excess outbound calls so no MCP server ever gets more than N in flight at onceLinear ticket
Resolves LIT-2749
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
@greptileaiand received a Confidence Score of at least 4/5 before requesting a maintainer reviewScreenshots / Proof of Fix
When LiteLLM needs to make several MCP tool calls it dispatches them with an unbounded
asyncio.gather, so every call hits the upstream server at the same instant. Backends that process requests in batches get overwhelmed and time out, and raising the per-call timeout does not help because the burst still lands all at once.Repro uses a small instrumented MCP server (streamable-http) exposing one tool,
slow_tool, that reports how many calls it is handling at the same moment. It is registered on a live proxy via configthen six tool calls are fired concurrently through the proxy
Before, with no cap, the backend sees all six at once
After, with
max_concurrent_requests: 2on that server, the backend never sees more than two and the rest queue until a slot freesType
🐛 Bug Fix
Changes
Adds an optional per-server
max_concurrent_requestsfield, mirroring the existingtimeoutfield across the request models, the DB table, the Prisma schema (with a migration), and the runtimeMCPServer. TheMCPServerManagerkeeps oneasyncio.Semaphoreperserver_id, created lazily from that value, and the outbound tool-call path acquires it before calling the upstream. Calls beyond the cap wait for a slot rather than being rejected, so a batch backend is never sent more than the configured number of simultaneous requests. Leaving the field unset preserves today's unbounded behavior, and a non-positive value is treated as unlimited so it can never deadlock on a zero-permit semaphore. The limiter is keyed onserver_id, so two servers never throttle each other and the cap survives the registry atomic-swap on config reload.The semaphore is in-process, so the cap is enforced per worker. The MCP gateway runs single-worker today, so per-process is the effective global cap; a Redis-backed distributed limiter is the natural follow-up when the gateway becomes multi-worker.
Known limitation: the semaphore is created once per
server_idand its permit count is fixed for the process lifetime, so changingmax_concurrent_requestson an already-active server does not take effect until the proxy restarts. Config-defined caps and newly created servers always reflect the configured value; only a runtime update to a live server is deferred to restart. Refreshing the limiter when the stored limit changes is a small follow-up.OpenAPI-backed MCP servers
The same cap is enforced for OpenAPI-spec servers (
spec_path), which dispatch through a separate handler. Verified live with a real OpenAPI spec pointing at a plain HTTP backend that reports its in-flight count, registered on the proxy and driven with six concurrent calls.Before, no cap
After, with
max_concurrent_requests: 2