diff --git a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py index a72465f16f4..a116c09d844 100644 --- a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py @@ -1255,7 +1255,18 @@ if MCP_AVAILABLE: MCPRequestHandler, ) - request: Final = _inherit_credentials_from_existing_server(new_mcp_server_request) + saved_server: Final = ( + global_mcp_server_manager.get_mcp_server_by_id(new_mcp_server_request.server_id) + if new_mcp_server_request.server_id + else None + ) + saved_origin: Final = _redact_mcp_resource_url(saved_server.url) if saved_server else None + may_inherit: Final = new_mcp_server_request.auth_type not in _STAGED_AUTH_VALUE_AUTH_TYPES or ( + saved_origin is not None and saved_origin == _redact_mcp_resource_url(new_mcp_server_request.url) + ) + request: Final = ( + _inherit_credentials_from_existing_server(new_mcp_server_request) if may_inherit else new_mcp_server_request + ) mcp_auth_header: Final = ( request.credentials.get("auth_value") if request.auth_type in _STAGED_AUTH_VALUE_AUTH_TYPES and isinstance(request.credentials, dict) diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py index 771a73c8ad0..ee5dabf043f 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py @@ -128,7 +128,7 @@ class TestExecuteWithMcpClient: payload: Final = NewMCPServerRequest( server_id=saved.server_id, server_name="edited", - url="https://edited.example/mcp", + url="https://stored.example/corrected-mcp", transport=MCPTransport.sse, auth_type=auth_type, credentials={"auth_value": auth_value} if auth_value is not None else None, @@ -146,13 +146,48 @@ class TestExecuteWithMcpClient: oauth2_headers=staged.oauth2_headers, ) assert result == { - "url": "https://edited.example/mcp", + "url": "https://stored.example/corrected-mcp", "transport": MCPTransport.sse, "headers": {"X-Preview": "edited", **expected_auth}, } assert manager.get_mcp_server_by_id(saved.server_id) is saved assert saved.url == "https://stored.example/mcp" + @pytest.mark.parametrize( + "url", ("https://other.example/mcp", "http://stored.example/mcp", "https://stored.example:8443/mcp") + ) + @pytest.mark.parametrize("explicit_credential", (None, "preview:explicit")) + def test_static_preview_does_not_inherit_credentials_across_origins( + self, monkeypatch: pytest.MonkeyPatch, url: str, explicit_credential: str | None + ) -> None: + from starlette.datastructures import Headers + + from litellm.proxy._experimental.mcp_server.mcp_server_manager import MCPServerManager + from litellm.proxy.management_endpoints import mcp_management_endpoints + + saved: Final = MCPServer( + server_id="saved-preview-server", + name="saved", + url="https://stored.example/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.basic, + authentication_token="preview:stored", + ) + manager: Final = MCPServerManager() + manager.registry = {saved.server_id: saved} + monkeypatch.setattr(rest_endpoints, "global_mcp_server_manager", manager) + monkeypatch.setattr(mcp_management_endpoints, "global_mcp_server_manager", manager) + payload: Final = NewMCPServerRequest( + server_id=saved.server_id, + url=url, + transport=MCPTransport.http, + auth_type=MCPAuth.basic, + credentials={"auth_value": explicit_credential} if explicit_credential else None, + ) + staged: Final = rest_endpoints._stage_server_test(payload, Headers()) + assert staged.mcp_auth_header == explicit_credential + assert staged.request.credentials == payload.credentials + @pytest.mark.asyncio async def test_redacts_stack_trace(self, monkeypatch): async def fake_create_client(*args, **kwargs): diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editToolPreview.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editToolPreview.test.ts index 9dcfc1a470e..c7147466d43 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editToolPreview.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editToolPreview.test.ts @@ -14,10 +14,10 @@ describe("getEditToolPreview", () => { }); it("previews URL changes with the existing server credential left for server-side inheritance", () => { - expect(getEditToolPreview({ ...saved, url: "https://correct.example/mcp" }, saved)).toEqual({ + expect(getEditToolPreview({ ...saved, url: "https://example.com/corrected-mcp" }, saved)).toEqual({ kind: "preview", config: { - url: "https://correct.example/mcp", + url: "https://example.com/corrected-mcp", transport: "http", auth_type: "basic", static_headers: { "X-Tenant": "original" }, @@ -26,6 +26,24 @@ describe("getEditToolPreview", () => { }); }); + it.each(["https://other.example/mcp", "http://example.com/mcp", "https://example.com:8443/mcp"])( + "requires explicit credentials for a changed origin: %s", + (url) => { + expect(getEditToolPreview({ ...saved, url, static_headers: [] }, saved)).toEqual({ + kind: "incomplete", + message: expect.stringContaining("origin changed"), + }); + const explicit = { ...saved, url, static_headers: [], credentials: { auth_value: "new:secret" } }; + expect(getEditToolPreview(explicit, saved).kind).toBe("preview"); + }, + ); + + it("does not automatically send saved static headers to a new origin", () => { + expect(getEditToolPreview({ ...saved, url: "https://other.example/mcp", auth_type: "none" }, saved).kind).toBe( + "incomplete", + ); + }); + it("uses edited static headers and only the static auth value", () => { expect( getEditToolPreview( diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editToolPreview.ts b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editToolPreview.ts index eb46c33c4e7..6dea7d7d18e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editToolPreview.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editToolPreview.ts @@ -21,7 +21,7 @@ const connectionConfig = (values: Readonly>) => { type EditToolPreview = | { readonly kind: "saved" } - | { readonly kind: "incomplete" } + | { readonly kind: "incomplete"; readonly message?: string } | { readonly kind: "preview"; readonly config: ReturnType }; export const getEditToolPreview = ( @@ -49,5 +49,19 @@ export const getEditToolPreview = ( if (!validUrl || missingNewCredential || incompleteHeaders) { return { kind: "incomplete" }; } + const savedConfig = connectionConfig(initialValues); + const changedOrigin = + !URL.canParse(savedConfig.url) || new URL(config.url).origin !== new URL(savedConfig.url).origin; + const reusesHeader = Object.entries(config.static_headers).some( + ([key, value]) => savedConfig.static_headers[key] === value, + ); + const needsSavedCredential = AUTH_TYPES_REQUIRING_AUTH_VALUE.includes(config.auth_type) && !config.credentials; + if (changedOrigin && (needsSavedCredential || reusesHeader)) { + return { + kind: "incomplete", + message: + "The server origin changed. Enter credentials and replace or remove saved static headers to preview tools.", + }; + } return { kind: "preview", config }; }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx index 5316a22ce27..2a37029a2c4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx @@ -454,19 +454,22 @@ const MCPServerEdit: React.FC = ({ const toolPreviewKey = JSON.stringify(toolPreview); useEffect(() => { - let active = true; + const controller = new AbortController(); setTools([]); setToolsError(null); setIsLoadingTools(false); if (!accessToken || !mcpServer.server_id) return; if (toolPreview.kind === "incomplete") { - setToolsError("Complete the URL, authentication, and header settings to load tools."); + setToolsError(toolPreview.message ?? "Complete the URL, authentication, and header settings to load tools."); return; } setIsLoadingTools(true); - const timer = setTimeout(() => fetchTools(() => active), toolPreview.kind === "preview" ? 500 : 0); + const timer = setTimeout( + () => fetchTools(() => !controller.signal.aborted), + toolPreview.kind === "preview" ? 500 : 0, + ); return () => { - active = false; + controller.abort(); clearTimeout(timer); }; // eslint-disable-next-line react-hooks/exhaustive-deps