From d6cbf6e7e320f64138ccb0bcb847baae394fde2b Mon Sep 17 00:00:00 2001 From: tin-berri Date: Tue, 7 Jul 2026 22:47:03 -0700 Subject: [PATCH] feat(ui): expose MCP max_concurrent_requests in server create and edit 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. --- .../mcp_server/mcp_server_manager.py | 15 ++-- .../test_mcp_max_concurrent_requests.py | 19 ++++ .../mcp_tools/create_mcp_server.test.tsx | 89 +++++++++++++++---- .../mcp_tools/create_mcp_server.tsx | 22 ++++- .../mcp_tools/mcp_server_edit.test.tsx | 80 +++++++++++++++++ .../components/mcp_tools/mcp_server_edit.tsx | 20 +++++ .../src/components/mcp_tools/types.tsx | 1 + 7 files changed, 220 insertions(+), 26 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 7c88c903324..39da1ba4a97 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -715,8 +715,10 @@ class MCPServerManager: # Per-server outbound tool-call concurrency limiters, lazily created from # each server's max_concurrent_requests. Keyed by server_id so the cap # survives the registry atomic-swap on config reload; a missing key means - # the server has no configured limit. - self._server_call_semaphores: dict[str, asyncio.Semaphore] = {} + # the server has no configured limit. The limit is cached alongside the + # semaphore so an edited limit rebuilds it instead of keeping the old cap + # until restart. + self._server_call_semaphores: dict[str, tuple[int, asyncio.Semaphore]] = {} self.tool_name_to_mcp_server_name_mapping: dict[str, str] = {} """ { @@ -3594,10 +3596,11 @@ class MCPServerManager: limit = mcp_server.max_concurrent_requests if limit is None or limit <= 0: return None - semaphore = self._server_call_semaphores.get(mcp_server.server_id) - if semaphore is None: - semaphore = asyncio.Semaphore(limit) - self._server_call_semaphores[mcp_server.server_id] = semaphore + cached = self._server_call_semaphores.get(mcp_server.server_id) + if cached is not None and cached[0] == limit: + return cached[1] + semaphore = asyncio.Semaphore(limit) + self._server_call_semaphores[mcp_server.server_id] = (limit, semaphore) return semaphore @asynccontextmanager diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_max_concurrent_requests.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_max_concurrent_requests.py index 8c4d81223aa..e11897b65c2 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_max_concurrent_requests.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_max_concurrent_requests.py @@ -164,6 +164,25 @@ async def test_openapi_backed_server_also_respects_the_cap(): assert tracker.peak_by_server["srv-openapi"] == 2 +@pytest.mark.asyncio +async def test_edited_limit_takes_effect_without_restart(): + """Editing max_concurrent_requests must rebuild the cached semaphore so the + new cap applies to subsequent calls immediately, not only after a restart.""" + manager = MCPServerManager() + server = _make_server("srv-edited", max_concurrent_requests=3) + + before_edit = _ConcurrencyTracker() + with _patch_client_with_tracker(manager, before_edit): + await _fire(manager, server, n=6) + assert before_edit.peak_by_server["srv-edited"] == 3 + + server.max_concurrent_requests = 1 + after_edit = _ConcurrencyTracker() + with _patch_client_with_tracker(manager, after_edit): + await _fire(manager, server, n=6) + assert after_edit.peak_by_server["srv-edited"] == 1 + + def test_semaphore_is_reused_per_server_and_distinct_across_servers(): manager = MCPServerManager() server_a = _make_server("srv-a", max_concurrent_requests=3) diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx index 9b4d159ae0c..36af2f8d9fc 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx @@ -388,16 +388,58 @@ describe("CreateMCPServer", () => { expect(screen.queryByText("Subject Token Type (optional)")).not.toBeInTheDocument(); }); - it("routes OAuth Token Exchange (OBO) config to the backend payload", async () => { + it("sends max_concurrent_requests in the create payload when set", async () => { await selectHttpTransport(); const user = userEvent.setup({ delay: null }); const nameInput = getServerNameInput(); - await user.type(nameInput, "TE_Server"); + await user.type(nameInput, "Limited_Server"); const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com"); - await user.type(urlInput, "https://upstream.example.com/mcp"); + await user.type(urlInput, "https://example.com/mcp"); + + await selectAntOption("Authentication", "None"); + + const limitInput = screen.getByPlaceholderText("e.g. 10"); + await user.type(limitInput, "5"); + + vi.mocked(networking.createMCPServer).mockResolvedValue({ + server_id: "new-server-1", + server_name: "Limited_Server", + alias: "Limited_Server", + url: "https://example.com/mcp", + transport: "http", + auth_type: "none", + created_at: "2024-01-01T00:00:00Z", + created_by: "user-1", + updated_at: "2024-01-01T00:00:00Z", + updated_by: "user-1", + }); + + const submitButton = screen.getByRole("button", { name: "Add MCP Server" }); + await act(async () => { + fireEvent.click(submitButton); + }); + + await waitFor(() => { + expect(networking.createMCPServer).toHaveBeenCalledTimes(1); + }); + + const [, payload] = vi.mocked(networking.createMCPServer).mock.calls[0]; + expect(payload.max_concurrent_requests).toBe(5); + }); + + it("routes OAuth Token Exchange (OBO) config to the backend payload", async () => { + await selectHttpTransport(); + + // fireEvent.change over user.type: this test asserts payload shape, not + // keystroke behavior, and char-by-char typing re-renders the whole form + // per character, which pushed this test past the 30s CI timeout. + fireEvent.change(getServerNameInput(), { target: { value: "TE_Server" } }); + + const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com"); + fireEvent.change(urlInput, { target: { value: "https://upstream.example.com/mcp" } }); await selectAntOption("Authentication", "OAuth Token Exchange (OBO)"); @@ -405,12 +447,15 @@ describe("CreateMCPServer", () => { expect(screen.getByPlaceholderText("https://idp.example.com/oauth2/token")).toBeInTheDocument(); }); - await user.type( - screen.getByPlaceholderText("https://idp.example.com/oauth2/token"), - "https://idp.example.com/oauth2/token", - ); - await user.type(screen.getByPlaceholderText("Enter OAuth client ID"), "te-client-id"); - await user.type(screen.getByPlaceholderText("Enter OAuth client secret"), "te-client-secret"); + fireEvent.change(screen.getByPlaceholderText("https://idp.example.com/oauth2/token"), { + target: { value: "https://idp.example.com/oauth2/token" }, + }); + fireEvent.change(screen.getByPlaceholderText("Enter OAuth client ID"), { + target: { value: "te-client-id" }, + }); + fireEvent.change(screen.getByPlaceholderText("Enter OAuth client secret"), { + target: { value: "te-client-secret" }, + }); vi.mocked(networking.createMCPServer).mockResolvedValue({ server_id: "new-server-te", @@ -447,10 +492,13 @@ describe("CreateMCPServer", () => { it("makes scope required when the Entra OBO profile is selected", async () => { await selectHttpTransport(); - const user = userEvent.setup({ delay: null }); - - await user.type(getServerNameInput(), "Entra_Server"); - await user.type(screen.getByPlaceholderText("https://your-mcp-server.com"), "https://upstream.example.com/mcp"); + // fireEvent.change over user.type for the same reason as the payload + // test above: char-by-char typing re-renders the whole form per + // character and pushes this test toward the 30s CI timeout. + fireEvent.change(getServerNameInput(), { target: { value: "Entra_Server" } }); + fireEvent.change(screen.getByPlaceholderText("https://your-mcp-server.com"), { + target: { value: "https://upstream.example.com/mcp" }, + }); await selectAntOption("Authentication", "OAuth Token Exchange (OBO)"); await waitFor(() => { @@ -459,12 +507,15 @@ describe("CreateMCPServer", () => { await selectAntOption("Profile", "Microsoft Entra OBO"); - await user.type( - screen.getByPlaceholderText("https://idp.example.com/oauth2/token"), - "https://login.microsoftonline.com/tenant/oauth2/v2.0/token", - ); - await user.type(screen.getByPlaceholderText("Enter OAuth client ID"), "entra-client"); - await user.type(screen.getByPlaceholderText("Enter OAuth client secret"), "entra-secret"); + fireEvent.change(screen.getByPlaceholderText("https://idp.example.com/oauth2/token"), { + target: { value: "https://login.microsoftonline.com/tenant/oauth2/v2.0/token" }, + }); + fireEvent.change(screen.getByPlaceholderText("Enter OAuth client ID"), { + target: { value: "entra-client" }, + }); + fireEvent.change(screen.getByPlaceholderText("Enter OAuth client secret"), { + target: { value: "entra-secret" }, + }); // Selecting Entra OBO makes the scope required; submitting without one is blocked by validation // (rfc8693 would not require it), which confirms the profile selection took effect. diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx index a4e09bd6f6b..10668468c15 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx @@ -1,5 +1,5 @@ import React, { useState } from "react"; -import { Modal, Tooltip, Form, Select, Input, Switch, Collapse } from "antd"; +import { Modal, Tooltip, Form, Select, Input, InputNumber, Switch, Collapse } from "antd"; import { InfoCircleOutlined } from "@ant-design/icons"; import { Button, TextInput } from "@tremor/react"; import { createMCPServer, registerMCPServer, storeMCPOAuthUserCredential } from "../networking"; @@ -929,6 +929,26 @@ const CreateMCPServer: React.FC = ({ )} + + Max Concurrent Requests (optional) + + + + + } + name="max_concurrent_requests" + > + + + {/* Authentication - show for HTTP, SSE, and OpenAPI */} {transportType !== "stdio" && transportType !== "" && ( { }); }); }); + +describe("MCPServerEdit (max concurrent requests)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const limitedServer = { + ...interactiveOAuthServer, + auth_type: "none", + max_concurrent_requests: 5, + }; + + it("prefills the existing limit and sends an updated value in the payload", async () => { + vi.mocked(networking.updateMCPServer).mockResolvedValue({ + ...limitedServer, + max_concurrent_requests: 2, + }); + + render( + , + ); + + const limitInput = screen.getByPlaceholderText("e.g. 10") as HTMLInputElement; + expect(limitInput.value).toBe("5"); + + fireEvent.change(limitInput, { target: { value: "2" } }); + + const saveButtons = screen.getAllByRole("button", { name: "Save Changes" }); + await act(async () => { + fireEvent.click(saveButtons[0]); + }); + + await waitFor(() => { + expect(networking.updateMCPServer).toHaveBeenCalledTimes(1); + }); + + const [, payload] = vi.mocked(networking.updateMCPServer).mock.calls[0]; + expect(payload.max_concurrent_requests).toBe(2); + }); + + it("sends null when the limit is cleared so the backend unsets it", async () => { + vi.mocked(networking.updateMCPServer).mockResolvedValue({ + ...limitedServer, + max_concurrent_requests: null, + }); + + render( + , + ); + + const limitInput = screen.getByPlaceholderText("e.g. 10") as HTMLInputElement; + expect(limitInput.value).toBe("5"); + + fireEvent.change(limitInput, { target: { value: "" } }); + + const saveButtons = screen.getAllByRole("button", { name: "Save Changes" }); + await act(async () => { + fireEvent.click(saveButtons[0]); + }); + + await waitFor(() => { + expect(networking.updateMCPServer).toHaveBeenCalledTimes(1); + }); + + const [, payload] = vi.mocked(networking.updateMCPServer).mock.calls[0]; + expect(payload.max_concurrent_requests).toBeNull(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx index faf7737e995..70632b459fc 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx @@ -852,6 +852,26 @@ const MCPServerEdit: React.FC = ({ )} + + Max Concurrent Requests (optional) + + + + + } + name="max_concurrent_requests" + > + + + {/* Authentication - for HTTP, SSE, and OpenAPI */} {!isStdioTransport && ( diff --git a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx index 7e02b8779f5..9469d7bd89e 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx @@ -261,6 +261,7 @@ export interface MCPServer { available_on_public_internet?: boolean; delegate_auth_to_upstream?: boolean; oauth_passthrough?: boolean; + max_concurrent_requests?: number | null; /** Stdio-only fields (present when transport === 'stdio') */ command?: string | null;