Skip to content

Commit cabaa2e

Browse files
authored
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
1 parent 2f6400d commit cabaa2e

8 files changed

Lines changed: 159 additions & 1 deletion

File tree

src/backend/base/langflow/api/v1/schemas/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,7 @@ class BaseConfigResponse(BaseModel):
379379
event_delivery: Literal["polling", "streaming", "direct"]
380380
voice_mode_available: bool
381381
frontend_timeout: int
382+
mcp_base_url: str
382383

383384

384385
class PublicConfigResponse(BaseConfigResponse):
@@ -407,6 +408,7 @@ def from_settings(cls, settings: Settings) -> "PublicConfigResponse":
407408
event_delivery=settings.event_delivery,
408409
voice_mode_available=settings.voice_mode_available,
409410
frontend_timeout=settings.frontend_timeout,
411+
mcp_base_url=settings.mcp_base_url,
410412
allow_custom_components=settings.allow_custom_components,
411413
)
412414

@@ -460,6 +462,7 @@ def from_settings(cls, settings: Settings, auth_settings) -> "ConfigResponse":
460462
public_flow_expiration=settings.public_flow_expiration,
461463
event_delivery=settings.event_delivery,
462464
voice_mode_available=settings.voice_mode_available,
465+
mcp_base_url=settings.mcp_base_url,
463466
webhook_auth_enable=auth_settings.WEBHOOK_AUTH_ENABLE,
464467
default_folder_name=DEFAULT_FOLDER_NAME,
465468
hide_getting_started_progress=os.getenv("HIDE_GETTING_STARTED_PROGRESS", "").lower() == "true",

src/backend/tests/unit/api/v1/test_endpoints.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,3 +291,41 @@ async def test_get_config_authenticated_returns_full_config(client: AsyncClient,
291291
assert "auto_saving_interval" in result, "Authenticated response must contain 'auto_saving_interval'"
292292
assert "health_check_max_retries" in result, "Authenticated response must contain 'health_check_max_retries'"
293293
assert "feature_flags" in result, "Authenticated response must contain 'feature_flags'"
294+
295+
296+
async def test_get_config_returns_mcp_base_url(client: AsyncClient, logged_in_headers: dict):
297+
"""Test that /config includes mcp_base_url for both authenticated and unauthenticated responses."""
298+
# Authenticated
299+
response = await client.get("api/v1/config", headers=logged_in_headers)
300+
result = response.json()
301+
assert response.status_code == status.HTTP_200_OK
302+
assert "mcp_base_url" in result, "Authenticated response must contain 'mcp_base_url'"
303+
assert isinstance(result["mcp_base_url"], str), "mcp_base_url must be a string"
304+
305+
# Unauthenticated
306+
response = await client.get("api/v1/config")
307+
result = response.json()
308+
assert response.status_code == status.HTTP_200_OK
309+
assert "mcp_base_url" in result, "Public response must contain 'mcp_base_url'"
310+
assert isinstance(result["mcp_base_url"], str), "mcp_base_url must be a string"
311+
312+
313+
async def test_get_config_mcp_base_url_defaults_to_empty(client: AsyncClient, logged_in_headers: dict):
314+
"""Test that mcp_base_url defaults to empty string when LANGFLOW_MCP_BASE_URL is not set."""
315+
response = await client.get("api/v1/config", headers=logged_in_headers)
316+
result = response.json()
317+
assert response.status_code == status.HTTP_200_OK
318+
assert result["mcp_base_url"] == ""
319+
320+
321+
async def test_get_config_mcp_base_url_from_settings(client: AsyncClient, logged_in_headers: dict, monkeypatch):
322+
"""Test that mcp_base_url reflects the value from settings."""
323+
from langflow.services.deps import get_settings_service
324+
325+
settings_service = get_settings_service()
326+
monkeypatch.setattr(settings_service.settings, "mcp_base_url", "https://langflow.example.com")
327+
328+
response = await client.get("api/v1/config", headers=logged_in_headers)
329+
result = response.json()
330+
assert response.status_code == status.HTTP_200_OK
331+
assert result["mcp_base_url"] == "https://langflow.example.com"

src/frontend/src/controllers/API/queries/config/use-get-config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ interface BaseConfig {
2020
event_delivery: EventDeliveryType;
2121
voice_mode_available: boolean;
2222
allow_custom_components: boolean;
23+
mcp_base_url: string;
2324
}
2425

2526
// Public config = base config (unauthenticated users get only base fields)
@@ -82,6 +83,7 @@ export const useGetConfig: useQueryFunctionType<
8283
const setAllowCustomComponents = useUtilityStore(
8384
(state) => state.setAllowCustomComponents,
8485
);
86+
const setMcpBaseUrl = useUtilityStore((state) => state.setMcpBaseUrl);
8587

8688
const { query } = UseRequestProcessor();
8789

@@ -104,6 +106,7 @@ export const useGetConfig: useQueryFunctionType<
104106
setEventDelivery(data.event_delivery ?? EventDeliveryType.POLLING);
105107
const allowCustomComponents = data.allow_custom_components ?? true;
106108
setAllowCustomComponents(allowCustomComponents);
109+
setMcpBaseUrl(data.mcp_base_url ?? "");
107110
recomputeComponentsToUpdateIfNeeded();
108111

109112
// Set authenticated-only fields if present (full config)
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { api } from "@/controllers/API/api";
2+
import { useUtilityStore } from "@/stores/utilityStore";
3+
import { customGetMCPUrl } from "../custom-mcp-url";
4+
5+
describe("customGetMCPUrl", () => {
6+
const originalBaseURL = api.defaults.baseURL;
7+
8+
afterEach(() => {
9+
api.defaults.baseURL = originalBaseURL;
10+
useUtilityStore.setState({ mcpBaseUrl: "" });
11+
});
12+
13+
it("uses mcpBaseUrl from store when set", () => {
14+
api.defaults.baseURL = "";
15+
useUtilityStore.setState({ mcpBaseUrl: "https://custom.example.com" });
16+
17+
const url = customGetMCPUrl("proj-1");
18+
19+
expect(url).toBe(
20+
"https://custom.example.com/api/v1/mcp/project/proj-1/streamable",
21+
);
22+
});
23+
24+
it("mcpBaseUrl takes priority over api.defaults.baseURL", () => {
25+
api.defaults.baseURL = "https://api-default.example.com";
26+
useUtilityStore.setState({ mcpBaseUrl: "https://override.example.com" });
27+
28+
const url = customGetMCPUrl("proj-1");
29+
30+
expect(url).toBe(
31+
"https://override.example.com/api/v1/mcp/project/proj-1/streamable",
32+
);
33+
});
34+
35+
it("falls back to api.defaults.baseURL when mcpBaseUrl is empty", () => {
36+
api.defaults.baseURL = "https://api-default.example.com";
37+
useUtilityStore.setState({ mcpBaseUrl: "" });
38+
39+
const url = customGetMCPUrl("proj-1");
40+
41+
expect(url).toBe(
42+
"https://api-default.example.com/api/v1/mcp/project/proj-1/streamable",
43+
);
44+
});
45+
46+
it("falls back to window.location.origin when both are empty", () => {
47+
api.defaults.baseURL = "";
48+
useUtilityStore.setState({ mcpBaseUrl: "" });
49+
50+
const url = customGetMCPUrl("proj-1");
51+
52+
expect(url).toBe(
53+
`${window.location.origin}/api/v1/mcp/project/proj-1/streamable`,
54+
);
55+
});
56+
57+
it("strips trailing slashes from mcpBaseUrl", () => {
58+
useUtilityStore.setState({ mcpBaseUrl: "https://example.com/" });
59+
60+
const url = customGetMCPUrl("proj-1");
61+
62+
expect(url).toBe(
63+
"https://example.com/api/v1/mcp/project/proj-1/streamable",
64+
);
65+
});
66+
67+
it("strips multiple trailing slashes", () => {
68+
useUtilityStore.setState({ mcpBaseUrl: "https://example.com///" });
69+
70+
const url = customGetMCPUrl("proj-1");
71+
72+
expect(url).toBe(
73+
"https://example.com/api/v1/mcp/project/proj-1/streamable",
74+
);
75+
});
76+
77+
it("returns SSE URL when transport is sse", () => {
78+
useUtilityStore.setState({ mcpBaseUrl: "https://example.com" });
79+
80+
const url = customGetMCPUrl("proj-1", {}, "sse");
81+
82+
expect(url).toBe("https://example.com/api/v1/mcp/project/proj-1/sse");
83+
});
84+
85+
it("returns composer URL when useComposer is true and streamableHttpUrl is set", () => {
86+
useUtilityStore.setState({ mcpBaseUrl: "https://should-not-use.com" });
87+
88+
const url = customGetMCPUrl(
89+
"proj-1",
90+
{
91+
useComposer: true,
92+
streamableHttpUrl: "https://composer.example.com/streamable",
93+
},
94+
"streamablehttp",
95+
);
96+
97+
expect(url).toBe("https://composer.example.com/streamable");
98+
});
99+
});

src/frontend/src/customization/utils/custom-mcp-url.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { api } from "@/controllers/API/api";
22
import type { MCPTransport } from "@/controllers/API/queries/mcp/use-patch-install-mcp";
3+
import { useUtilityStore } from "@/stores/utilityStore";
34

45
type ComposerConnectionOptions = {
56
useComposer?: boolean;
@@ -26,7 +27,12 @@ export const customGetMCPUrl = (
2627
}
2728
}
2829

29-
const apiHost = api.defaults.baseURL || window.location.origin;
30+
const configBaseUrl = useUtilityStore.getState().mcpBaseUrl;
31+
const apiHost = (
32+
configBaseUrl ||
33+
api.defaults.baseURL ||
34+
window.location.origin
35+
).replace(/\/+$/, "");
3036
const baseUrl = `${apiHost}/api/v1/mcp/project/${projectId}`;
3137
return transport === "streamablehttp"
3238
? `${baseUrl}/streamable`

src/frontend/src/stores/utilityStore.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,6 @@ export const useUtilityStore = create<UtilityStoreType>((set, get) => ({
6464
allowCustomComponents: true,
6565
setAllowCustomComponents: (allowCustomComponents: boolean) =>
6666
set({ allowCustomComponents }),
67+
mcpBaseUrl: "",
68+
setMcpBaseUrl: (mcpBaseUrl: string) => set({ mcpBaseUrl }),
6769
}));

src/frontend/src/types/zustand/utility/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,6 @@ export type UtilityStoreType = {
3838
setHideGettingStartedProgress: (hideGettingStartedProgress: boolean) => void;
3939
allowCustomComponents: boolean;
4040
setAllowCustomComponents: (allowCustomComponents: boolean) => void;
41+
mcpBaseUrl: string;
42+
setMcpBaseUrl: (mcpBaseUrl: string) => void;
4143
};

src/lfx/src/lfx/services/settings/base.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,11 @@ class Settings(BaseSettings):
9292
If not provided, a hash of the database URL will be used. Useful when multiple Langflow
9393
instances share the same database and need coordinated migration locking."""
9494

95+
mcp_base_url: str = ""
96+
"""External base URL used to build MCP server URLs in the UI configuration JSON
97+
(e.g. 'https://langflow.example.com'). When empty, the frontend falls back to
98+
the browser's window.location.origin."""
99+
95100
mcp_server_timeout: int = 20
96101
"""The number of seconds to wait before giving up on a lock to released or establishing a connection to the
97102
database."""

0 commit comments

Comments
 (0)