From f884d4cf6d2dc2c4995006edf767004ae4d146ae Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Sat, 28 Feb 2026 18:18:05 -0800 Subject: [PATCH] fix(mcp): refresh tools when editing MCP server URL When editing an MCP server's URL, the UI was caching stale tool lists from the previous server. The tool configuration component received static database values instead of live form values, so URL changes weren't detected. Changes: - Watch URL/spec_path form fields with Form.useWatch and pass live values to MCPToolConfiguration - Clear allowedTools when URL changes to trigger auto-select of new tools - Improve tool change detection by tracking tool names instead of count, to handle cases where two servers have the same number of tools - Add tests verifying URL changes clear allowedTools and pass live values Fixes LIT-1790 Co-Authored-By: Claude Haiku 4.5 --- ...odel_prices_and_context_window_backup.json | 2 +- .../mcp_tools/mcp_server_edit.test.tsx | 99 ++++++++++++++++++- .../components/mcp_tools/mcp_server_edit.tsx | 19 +++- .../mcp_tools/mcp_tool_configuration.tsx | 21 ++-- 4 files changed, 128 insertions(+), 13 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index f52288ea72a..cbd64a178b8 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -16289,7 +16289,7 @@ "cache_read_input_token_cost": 3e-08, "input_cost_per_audio_token": 1e-06, "input_cost_per_token": 3e-07, - "litellm_provider": "vertex_ai-language-models", + "litellm_provider": "gemini", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "supports_reasoning": false, diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx index e33e2fff491..eb546378776 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx @@ -33,8 +33,9 @@ vi.mock("./MCPPermissionManagement", () => ({ default: () =>
, })); +const MockMCPToolConfiguration = vi.fn(() =>
); vi.mock("./mcp_tool_configuration", () => ({ - default: () =>
, + default: (props: any) => MockMCPToolConfiguration(props), })); describe("MCPServerEdit (stdio)", () => { @@ -152,3 +153,99 @@ describe("MCPServerEdit (stdio)", () => { expect(payload.env).toEqual({ CIRCLECI_TOKEN: "new-token", CIRCLECI_BASE_URL: "https://circleci.com" }); }); }); + +describe("MCPServerEdit (URL change clears allowed tools)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should pass live URL to MCPToolConfiguration when URL field changes", async () => { + render( + , + ); + + // Initially, MCPToolConfiguration should receive the original URL + const initialCall = MockMCPToolConfiguration.mock.calls.find( + (call) => call[0]?.formValues?.url === "https://exa.example.com/mcp" + ); + expect(initialCall).toBeDefined(); + + // Change the URL field + const urlInput = screen.getByLabelText("MCP Server URL"); + await act(async () => { + fireEvent.change(urlInput, { + target: { value: "https://seolinkmap.com/mcp" }, + }); + }); + + // After URL change, MCPToolConfiguration should receive the new URL + await waitFor(() => { + const latestCall = MockMCPToolConfiguration.mock.calls[MockMCPToolConfiguration.mock.calls.length - 1]; + expect(latestCall[0].formValues.url).toBe("https://seolinkmap.com/mcp"); + }); + }); + + it("should clear allowedTools when URL changes from original", async () => { + render( + , + ); + + // Initially, allowedTools should be the existing tools + const initialCall = MockMCPToolConfiguration.mock.calls[MockMCPToolConfiguration.mock.calls.length - 1]; + expect(initialCall[0].allowedTools).toEqual(["web_search_exa", "get_code_context_exa"]); + + // Change the URL field to a different server + const urlInput = screen.getByLabelText("MCP Server URL"); + await act(async () => { + fireEvent.change(urlInput, { + target: { value: "https://seolinkmap.com/mcp" }, + }); + }); + + // After URL change, allowedTools should be cleared + await waitFor(() => { + const latestCall = MockMCPToolConfiguration.mock.calls[MockMCPToolConfiguration.mock.calls.length - 1]; + expect(latestCall[0].allowedTools).toEqual([]); + }); + }); +}); 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 4209d8bf111..c4f1bc85832 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 @@ -41,6 +41,8 @@ const MCPServerEdit: React.FC = ({ const [pendingRestoredValues, setPendingRestoredValues] = useState | null>(null); const authType = Form.useWatch("auth_type", form) as string | undefined; const transportType = Form.useWatch("transport", form) as string | undefined; + const urlValue = Form.useWatch("url", form) as string | undefined; + const specPathValue = Form.useWatch("spec_path", form) as string | undefined; const isStdioTransport = transportType === "stdio"; const isOpenAPITransport = transportType === TRANSPORT.OPENAPI; const isMCPTransport = !isStdioTransport && !isOpenAPITransport; @@ -191,6 +193,16 @@ const MCPServerEdit: React.FC = ({ } }, [mcpServer]); + // Clear allowed tools when URL or spec_path changes to a different server + // so that the auto-select logic in MCPToolConfiguration picks up the new tools + useEffect(() => { + const currentEndpoint = urlValue ?? specPathValue; + const originalEndpoint = mcpServer.url ?? mcpServer.spec_path; + if (currentEndpoint !== undefined && currentEndpoint !== originalEndpoint) { + setAllowedTools([]); + } + }, [urlValue, specPathValue, mcpServer.url, mcpServer.spec_path]); + useEffect(() => { if (typeof window === "undefined") { return; @@ -880,9 +892,10 @@ const MCPServerEdit: React.FC = ({ formValues={{ server_id: mcpServer.server_id, server_name: mcpServer.server_name, - url: mcpServer.url, - transport: mcpServer.transport, - auth_type: mcpServer.auth_type, + url: urlValue ?? mcpServer.url, + spec_path: specPathValue ?? mcpServer.spec_path, + transport: transportType ?? mcpServer.transport, + auth_type: authType ?? mcpServer.auth_type, mcp_info: mcpServer.mcp_info, oauth_flow_type: mcpServer.token_url ? OAUTH_FLOW.M2M : OAUTH_FLOW.INTERACTIVE, }} diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_tool_configuration.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_tool_configuration.tsx index 404172623b9..46b3f3ce8aa 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_tool_configuration.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_tool_configuration.tsx @@ -21,7 +21,7 @@ const MCPToolConfiguration: React.FC = ({ existingAllowedTools, onAllowedToolsChange, }) => { - const previousToolsLengthRef = useRef(0); + const previousToolNamesRef = useRef(""); const [toolSearchTerm, setToolSearchTerm] = useState(""); const { tools, isLoadingTools, toolsError, canFetchTools } = useTestMCPConnection({ @@ -40,27 +40,32 @@ const MCPToolConfiguration: React.FC = ({ ); }); - // Auto-select tools when tools are first loaded + // Auto-select tools when tools are first loaded or when tool list changes (e.g. URL changed) useEffect(() => { + const currentToolNames = tools.map((t) => t.name).sort().join(","); // Only auto-select if: // 1. We have tools - // 2. Tools length changed (new tools loaded) - // 3. No tools are currently selected (initial state) - if (tools.length > 0 && tools.length !== previousToolsLengthRef.current && allowedTools.length === 0) { + // 2. Tool names changed from last fetch (detects both count and content changes) + // 3. No tools are currently selected (initial state or cleared after URL change) + if (tools.length > 0 && currentToolNames !== previousToolNamesRef.current && allowedTools.length === 0) { if (existingAllowedTools && existingAllowedTools.length > 0) { // If we have existing allowed tools, use those as the initial selection // Filter to only include tools that are actually available from the server const availableToolNames = tools.map((tool) => tool.name); const validExistingTools = existingAllowedTools.filter((toolName) => availableToolNames.includes(toolName)); - onAllowedToolsChange(validExistingTools); + if (validExistingTools.length > 0) { + onAllowedToolsChange(validExistingTools); + } else { + // No existing tools match the new server — select all new tools + onAllowedToolsChange(availableToolNames); + } } else { // If no existing allowed tools, auto-select all tools (create mode) const allToolNames = tools.map((tool) => tool.name); onAllowedToolsChange(allToolNames); } } - // Update ref to track tools length (will be 0 when tools clear) - previousToolsLengthRef.current = tools.length; + previousToolNamesRef.current = currentToolNames; }, [tools, allowedTools.length, existingAllowedTools, onAllowedToolsChange]); const handleToolToggle = (toolName: string) => {