feat: add LANGFLOW_MCP_BASE_URL config for MCP server URL override (#12523)

* feat: add mcp_base_url to config endpoint for MCP server URL override

Add LANGFLOW_MCP_BASE_URL setting that the frontend uses as a fallback
when building MCP server URLs in the UI configuration JSON. This allows
deployments behind reverse proxies to specify the correct external URL.

Priority chain: mcp_base_url > api.defaults.baseURL > window.location.origin

* test: add tests for mcp_base_url config and URL fallback priority
This commit is contained in:
Gabriel Luiz Freitas Almeida
2026-04-06 15:56:41 -03:00
committed by GitHub
parent 2f6400db89
commit cabaa2ef19
8 changed files with 159 additions and 1 deletions

View File

@ -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",

View File

@ -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"

View File

@ -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)

View File

@ -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");
});
});

View File

@ -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`

View File

@ -64,4 +64,6 @@ export const useUtilityStore = create<UtilityStoreType>((set, get) => ({
allowCustomComponents: true,
setAllowCustomComponents: (allowCustomComponents: boolean) =>
set({ allowCustomComponents }),
mcpBaseUrl: "",
setMcpBaseUrl: (mcpBaseUrl: string) => set({ mcpBaseUrl }),
}));

View File

@ -38,4 +38,6 @@ export type UtilityStoreType = {
setHideGettingStartedProgress: (hideGettingStartedProgress: boolean) => void;
allowCustomComponents: boolean;
setAllowCustomComponents: (allowCustomComponents: boolean) => void;
mcpBaseUrl: string;
setMcpBaseUrl: (mcpBaseUrl: string) => void;
};

View File

@ -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."""