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;