diff --git a/src/backend/base/langflow/api/v1/schemas/__init__.py b/src/backend/base/langflow/api/v1/schemas/__init__.py index 018ef4081a..e25a42189c 100644 --- a/src/backend/base/langflow/api/v1/schemas/__init__.py +++ b/src/backend/base/langflow/api/v1/schemas/__init__.py @@ -379,6 +379,7 @@ class BaseConfigResponse(BaseModel): event_delivery: Literal["polling", "streaming", "direct"] voice_mode_available: bool frontend_timeout: int + mcp_base_url: str class PublicConfigResponse(BaseConfigResponse): @@ -407,6 +408,7 @@ class PublicConfigResponse(BaseConfigResponse): event_delivery=settings.event_delivery, voice_mode_available=settings.voice_mode_available, frontend_timeout=settings.frontend_timeout, + mcp_base_url=settings.mcp_base_url, allow_custom_components=settings.allow_custom_components, ) @@ -460,6 +462,7 @@ class ConfigResponse(BaseConfigResponse): public_flow_expiration=settings.public_flow_expiration, event_delivery=settings.event_delivery, voice_mode_available=settings.voice_mode_available, + mcp_base_url=settings.mcp_base_url, webhook_auth_enable=auth_settings.WEBHOOK_AUTH_ENABLE, default_folder_name=DEFAULT_FOLDER_NAME, hide_getting_started_progress=os.getenv("HIDE_GETTING_STARTED_PROGRESS", "").lower() == "true", diff --git a/src/backend/tests/unit/api/v1/test_endpoints.py b/src/backend/tests/unit/api/v1/test_endpoints.py index 8820e65d2f..d94c184102 100644 --- a/src/backend/tests/unit/api/v1/test_endpoints.py +++ b/src/backend/tests/unit/api/v1/test_endpoints.py @@ -291,3 +291,41 @@ async def test_get_config_authenticated_returns_full_config(client: AsyncClient, assert "auto_saving_interval" in result, "Authenticated response must contain 'auto_saving_interval'" assert "health_check_max_retries" in result, "Authenticated response must contain 'health_check_max_retries'" assert "feature_flags" in result, "Authenticated response must contain 'feature_flags'" + + +async def test_get_config_returns_mcp_base_url(client: AsyncClient, logged_in_headers: dict): + """Test that /config includes mcp_base_url for both authenticated and unauthenticated responses.""" + # Authenticated + response = await client.get("api/v1/config", headers=logged_in_headers) + result = response.json() + assert response.status_code == status.HTTP_200_OK + assert "mcp_base_url" in result, "Authenticated response must contain 'mcp_base_url'" + assert isinstance(result["mcp_base_url"], str), "mcp_base_url must be a string" + + # Unauthenticated + response = await client.get("api/v1/config") + result = response.json() + assert response.status_code == status.HTTP_200_OK + assert "mcp_base_url" in result, "Public response must contain 'mcp_base_url'" + assert isinstance(result["mcp_base_url"], str), "mcp_base_url must be a string" + + +async def test_get_config_mcp_base_url_defaults_to_empty(client: AsyncClient, logged_in_headers: dict): + """Test that mcp_base_url defaults to empty string when LANGFLOW_MCP_BASE_URL is not set.""" + response = await client.get("api/v1/config", headers=logged_in_headers) + result = response.json() + assert response.status_code == status.HTTP_200_OK + assert result["mcp_base_url"] == "" + + +async def test_get_config_mcp_base_url_from_settings(client: AsyncClient, logged_in_headers: dict, monkeypatch): + """Test that mcp_base_url reflects the value from settings.""" + from langflow.services.deps import get_settings_service + + settings_service = get_settings_service() + monkeypatch.setattr(settings_service.settings, "mcp_base_url", "https://langflow.example.com") + + response = await client.get("api/v1/config", headers=logged_in_headers) + result = response.json() + assert response.status_code == status.HTTP_200_OK + assert result["mcp_base_url"] == "https://langflow.example.com" diff --git a/src/frontend/src/controllers/API/queries/config/use-get-config.ts b/src/frontend/src/controllers/API/queries/config/use-get-config.ts index 01a52285b5..8b66ed1b2c 100644 --- a/src/frontend/src/controllers/API/queries/config/use-get-config.ts +++ b/src/frontend/src/controllers/API/queries/config/use-get-config.ts @@ -20,6 +20,7 @@ interface BaseConfig { event_delivery: EventDeliveryType; voice_mode_available: boolean; allow_custom_components: boolean; + mcp_base_url: string; } // Public config = base config (unauthenticated users get only base fields) @@ -82,6 +83,7 @@ export const useGetConfig: useQueryFunctionType< const setAllowCustomComponents = useUtilityStore( (state) => state.setAllowCustomComponents, ); + const setMcpBaseUrl = useUtilityStore((state) => state.setMcpBaseUrl); const { query } = UseRequestProcessor(); @@ -104,6 +106,7 @@ export const useGetConfig: useQueryFunctionType< setEventDelivery(data.event_delivery ?? EventDeliveryType.POLLING); const allowCustomComponents = data.allow_custom_components ?? true; setAllowCustomComponents(allowCustomComponents); + setMcpBaseUrl(data.mcp_base_url ?? ""); recomputeComponentsToUpdateIfNeeded(); // Set authenticated-only fields if present (full config) diff --git a/src/frontend/src/customization/utils/__tests__/custom-mcp-url.test.ts b/src/frontend/src/customization/utils/__tests__/custom-mcp-url.test.ts new file mode 100644 index 0000000000..817e6564c8 --- /dev/null +++ b/src/frontend/src/customization/utils/__tests__/custom-mcp-url.test.ts @@ -0,0 +1,99 @@ +import { api } from "@/controllers/API/api"; +import { useUtilityStore } from "@/stores/utilityStore"; +import { customGetMCPUrl } from "../custom-mcp-url"; + +describe("customGetMCPUrl", () => { + const originalBaseURL = api.defaults.baseURL; + + afterEach(() => { + api.defaults.baseURL = originalBaseURL; + useUtilityStore.setState({ mcpBaseUrl: "" }); + }); + + it("uses mcpBaseUrl from store when set", () => { + api.defaults.baseURL = ""; + useUtilityStore.setState({ mcpBaseUrl: "https://custom.example.com" }); + + const url = customGetMCPUrl("proj-1"); + + expect(url).toBe( + "https://custom.example.com/api/v1/mcp/project/proj-1/streamable", + ); + }); + + it("mcpBaseUrl takes priority over api.defaults.baseURL", () => { + api.defaults.baseURL = "https://api-default.example.com"; + useUtilityStore.setState({ mcpBaseUrl: "https://override.example.com" }); + + const url = customGetMCPUrl("proj-1"); + + expect(url).toBe( + "https://override.example.com/api/v1/mcp/project/proj-1/streamable", + ); + }); + + it("falls back to api.defaults.baseURL when mcpBaseUrl is empty", () => { + api.defaults.baseURL = "https://api-default.example.com"; + useUtilityStore.setState({ mcpBaseUrl: "" }); + + const url = customGetMCPUrl("proj-1"); + + expect(url).toBe( + "https://api-default.example.com/api/v1/mcp/project/proj-1/streamable", + ); + }); + + it("falls back to window.location.origin when both are empty", () => { + api.defaults.baseURL = ""; + useUtilityStore.setState({ mcpBaseUrl: "" }); + + const url = customGetMCPUrl("proj-1"); + + expect(url).toBe( + `${window.location.origin}/api/v1/mcp/project/proj-1/streamable`, + ); + }); + + it("strips trailing slashes from mcpBaseUrl", () => { + useUtilityStore.setState({ mcpBaseUrl: "https://example.com/" }); + + const url = customGetMCPUrl("proj-1"); + + expect(url).toBe( + "https://example.com/api/v1/mcp/project/proj-1/streamable", + ); + }); + + it("strips multiple trailing slashes", () => { + useUtilityStore.setState({ mcpBaseUrl: "https://example.com///" }); + + const url = customGetMCPUrl("proj-1"); + + expect(url).toBe( + "https://example.com/api/v1/mcp/project/proj-1/streamable", + ); + }); + + it("returns SSE URL when transport is sse", () => { + useUtilityStore.setState({ mcpBaseUrl: "https://example.com" }); + + const url = customGetMCPUrl("proj-1", {}, "sse"); + + expect(url).toBe("https://example.com/api/v1/mcp/project/proj-1/sse"); + }); + + it("returns composer URL when useComposer is true and streamableHttpUrl is set", () => { + useUtilityStore.setState({ mcpBaseUrl: "https://should-not-use.com" }); + + const url = customGetMCPUrl( + "proj-1", + { + useComposer: true, + streamableHttpUrl: "https://composer.example.com/streamable", + }, + "streamablehttp", + ); + + expect(url).toBe("https://composer.example.com/streamable"); + }); +}); diff --git a/src/frontend/src/customization/utils/custom-mcp-url.ts b/src/frontend/src/customization/utils/custom-mcp-url.ts index a0ede5f69a..40210e807f 100644 --- a/src/frontend/src/customization/utils/custom-mcp-url.ts +++ b/src/frontend/src/customization/utils/custom-mcp-url.ts @@ -1,5 +1,6 @@ import { api } from "@/controllers/API/api"; import type { MCPTransport } from "@/controllers/API/queries/mcp/use-patch-install-mcp"; +import { useUtilityStore } from "@/stores/utilityStore"; type ComposerConnectionOptions = { useComposer?: boolean; @@ -26,7 +27,12 @@ export const customGetMCPUrl = ( } } - const apiHost = api.defaults.baseURL || window.location.origin; + const configBaseUrl = useUtilityStore.getState().mcpBaseUrl; + const apiHost = ( + configBaseUrl || + api.defaults.baseURL || + window.location.origin + ).replace(/\/+$/, ""); const baseUrl = `${apiHost}/api/v1/mcp/project/${projectId}`; return transport === "streamablehttp" ? `${baseUrl}/streamable` diff --git a/src/frontend/src/stores/utilityStore.ts b/src/frontend/src/stores/utilityStore.ts index 26ae4b5ec3..dd7a39a36b 100644 --- a/src/frontend/src/stores/utilityStore.ts +++ b/src/frontend/src/stores/utilityStore.ts @@ -64,4 +64,6 @@ export const useUtilityStore = create((set, get) => ({ allowCustomComponents: true, setAllowCustomComponents: (allowCustomComponents: boolean) => set({ allowCustomComponents }), + mcpBaseUrl: "", + setMcpBaseUrl: (mcpBaseUrl: string) => set({ mcpBaseUrl }), })); diff --git a/src/frontend/src/types/zustand/utility/index.ts b/src/frontend/src/types/zustand/utility/index.ts index 654ed528ef..9bf67e3e04 100644 --- a/src/frontend/src/types/zustand/utility/index.ts +++ b/src/frontend/src/types/zustand/utility/index.ts @@ -38,4 +38,6 @@ export type UtilityStoreType = { setHideGettingStartedProgress: (hideGettingStartedProgress: boolean) => void; allowCustomComponents: boolean; setAllowCustomComponents: (allowCustomComponents: boolean) => void; + mcpBaseUrl: string; + setMcpBaseUrl: (mcpBaseUrl: string) => void; }; diff --git a/src/lfx/src/lfx/services/settings/base.py b/src/lfx/src/lfx/services/settings/base.py index 89ef4e896b..08e9eabda5 100644 --- a/src/lfx/src/lfx/services/settings/base.py +++ b/src/lfx/src/lfx/services/settings/base.py @@ -92,6 +92,11 @@ class Settings(BaseSettings): If not provided, a hash of the database URL will be used. Useful when multiple Langflow instances share the same database and need coordinated migration locking.""" + mcp_base_url: str = "" + """External base URL used to build MCP server URLs in the UI configuration JSON + (e.g. 'https://langflow.example.com'). When empty, the frontend falls back to + the browser's window.location.origin.""" + mcp_server_timeout: int = 20 """The number of seconds to wait before giving up on a lock to released or establishing a connection to the database."""