mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
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.
This commit is contained in:
parent
6df5e1b263
commit
d6cbf6e7e3
7 changed files with 220 additions and 26 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<CreateMCPServerProps> = ({
|
|||
</>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
Max Concurrent Requests (optional)
|
||||
<Tooltip title="Maximum number of tool calls LiteLLM will run against this server at the same time. Additional calls wait for a free slot. Leave blank for no limit.">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="max_concurrent_requests"
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
precision={0}
|
||||
placeholder="e.g. 10"
|
||||
style={{ width: "100%" }}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* Authentication - show for HTTP, SSE, and OpenAPI */}
|
||||
{transportType !== "stdio" && transportType !== "" && (
|
||||
<Collapse
|
||||
|
|
|
|||
|
|
@ -1305,3 +1305,83 @@ describe("MCPServerEdit OAuth flow prefill display", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
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(
|
||||
<MCPServerEdit
|
||||
mcpServer={limitedServer}
|
||||
accessToken="access-token"
|
||||
onCancel={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
availableAccessGroups={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<MCPServerEdit
|
||||
mcpServer={limitedServer}
|
||||
accessToken="access-token"
|
||||
onCancel={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
availableAccessGroups={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -852,6 +852,26 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
Max Concurrent Requests (optional)
|
||||
<Tooltip title="Maximum number of tool calls LiteLLM will run against this server at the same time. Additional calls wait for a free slot. Leave blank for no limit.">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="max_concurrent_requests"
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
precision={0}
|
||||
placeholder="e.g. 10"
|
||||
style={{ width: "100%" }}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* Authentication - for HTTP, SSE, and OpenAPI */}
|
||||
{!isStdioTransport && (
|
||||
<Form.Item label="Authentication" name="auth_type" rules={[{ required: true }]}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue