Skip to content

feat(mcp): bound outbound tool-call concurrency per MCP server - #31641

Merged
tin-berri merged 1 commit into
litellm_internal_stagingfrom
litellm_mcp_per_server_concurrency_limit
Jul 2, 2026
Merged

feat(mcp): bound outbound tool-call concurrency per MCP server#31641
tin-berri merged 1 commit into
litellm_internal_stagingfrom
litellm_mcp_per_server_concurrency_limit

Conversation

@tin-berri

@tin-berri tin-berri commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Relevant issues

  • Root cause: LiteLLM dispatches MCP tool calls to each upstream with unbounded concurrency, so backends that process requests in batches get overwhelmed and time out under load
  • Fix: add an optional per-server max_concurrent_requests cap that queues excess outbound calls so no MCP server ever gets more than N in flight at once

Linear ticket

Resolves LIT-2749

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have requested a Greptile review by commenting @greptileai and received a Confidence Score of at least 4/5 before requesting a maintainer review

Screenshots / 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 config

mcp_servers:
  batch_backend:
    url: "http://127.0.0.1:9100/mcp"
    transport: "http"

then six tool calls are fired concurrently through the proxy

for i in $(seq 1 6); do
  curl -s -X POST http://127.0.0.1:4000/mcp-rest/tools/call \
    -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" \
    -d '{"server_id":"55c09d47c38f455f356f325a176b49e3","name":"slow_tool","arguments":{}}' &
done; wait

Before, with no cap, the backend sees all six at once

in_flight_at_entry -> 1 2 3 4 5 6   (peak concurrency 6)

After, with max_concurrent_requests: 2 on that server, the backend never sees more than two and the rest queue until a slot frees

in_flight_at_entry -> 1 1 1 2 2 2   (peak concurrency 2)

Type

🐛 Bug Fix

Changes

Adds an optional per-server max_concurrent_requests field, mirroring the existing timeout field across the request models, the DB table, the Prisma schema (with a migration), and the runtime MCPServer. The MCPServerManager keeps one asyncio.Semaphore per server_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 on server_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_id and its permit count is fixed for the process lifetime, so changing max_concurrent_requests on 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

in_flight_at_entry -> 1 2 3 4 5 6   (peak concurrency 6)

After, with max_concurrent_requests: 2

in_flight_at_entry -> 1 2 2 2 2 2   (peak concurrency 2)

@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@codecov

codecov Bot commented Jun 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@tin-berri
tin-berri force-pushed the litellm_mcp_per_server_concurrency_limit branch from 002a63e to 73dec1b Compare June 29, 2026 20:10
@greptile-apps

greptile-apps Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds an optional outbound concurrency cap for MCP server tool calls. The main changes are:

  • Adds max_concurrent_requests to MCP server schemas, request models, and runtime models.
  • Applies a per-server semaphore around regular MCP and OpenAPI-backed outbound tool calls.
  • Keeps unset and non-positive values unbounded for backward-compatible behavior.
  • Adds tests for capped, uncapped, non-positive, per-server, and OpenAPI concurrency behavior.

Confidence Score: 5/5

The 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.

T-Rex T-Rex Logs

What T-Rex did

  • Ran the MCP concurrency cap base test and observed in_flight_at_entry values 1 through 6 with peaks up to 6, indicating unbounded upstream concurrency.
  • Ran the MCP concurrency cap head test and observed batched entries in groups of two, with peaks at 2 and six successful results, indicating queueing rather than rejection.
  • Ran the MCP concurrency edge-case test and observed per-server isolation peaks (srv-a=1, srv-b=1) with a global peak of 2, plus semaphore_reused behavior of peak 2.
  • Observed pre-change API fields: base output showed all models valid (200 OK) but the with_field dump reported has_field=false and value=null for max_concurrent_requests.
  • Observed post-change API fields: head output showed with_field=true and value=2 for several MCP server request models, while the after state maintained 200 OK with value=null for absent fields.
  • Compared pre-migration schema against head migration: an initial commit had no max_concurrent_requests column and inserts failed with 'no column named max_concurrent_requests'.
  • Applied the head migration and confirmed max_concurrent_requests exists as INTEGER across schema copies; Python model uses Optional[int] = None; rows persist NULL and positive values; Prisma validation succeeds with DATABASE_URL.

View all artifacts

T-Rex Ran code and verified through T-Rex

Reviews (5): Last reviewed commit: "feat(mcp): bound outbound tool-call conc..." | Re-trigger Greptile

@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

Comment thread litellm/proxy/_experimental/mcp_server/mcp_server_manager.py
@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@tin-berri
tin-berri force-pushed the litellm_mcp_per_server_concurrency_limit branch from 73dec1b to 634f254 Compare June 29, 2026 20:13
Comment thread litellm/proxy/_experimental/mcp_server/mcp_server_manager.py
@veria-ai

veria-ai Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No 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
@tin-berri
tin-berri force-pushed the litellm_mcp_per_server_concurrency_limit branch from 634f254 to 0dbb48c Compare June 29, 2026 20:22
@tin-berri

Copy link
Copy Markdown
Contributor Author

Good catch on the OpenAPI path. Fixed in the latest commit: the limiter now wraps both dispatch branches in call_tool. OpenAPI-backed servers (spec_path) acquire the same per-server semaphore via _call_openapi_tool_handler, so max_concurrent_requests is honored regardless of MCP server type. Added a regression test (test_openapi_backed_server_also_respects_the_cap) that drives the OpenAPI branch and asserts peak in-flight stays at the cap; it fails if the wrap is removed.

@greptileai

@mateo-berri mateo-berri left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We gotta be careful to bump proxy extras for this migration. Otherwise LGTM, thanks!

@tin-berri
tin-berri merged commit 58de920 into litellm_internal_staging Jul 2, 2026
128 checks passed
@tin-berri
tin-berri deleted the litellm_mcp_per_server_concurrency_limit branch July 2, 2026 22:33
tin-berri added a commit that referenced this pull request Jul 8, 2026
…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.
tin-berri added a commit that referenced this pull request Jul 8, 2026
…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.
edelauna pushed a commit to edelauna/litellm that referenced this pull request Jul 22, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants