mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-26 15:07:25 +08:00
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:
committed by
GitHub
parent
2f6400db89
commit
cabaa2ef19
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
@ -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`
|
||||
|
||||
@ -64,4 +64,6 @@ export const useUtilityStore = create<UtilityStoreType>((set, get) => ({
|
||||
allowCustomComponents: true,
|
||||
setAllowCustomComponents: (allowCustomComponents: boolean) =>
|
||||
set({ allowCustomComponents }),
|
||||
mcpBaseUrl: "",
|
||||
setMcpBaseUrl: (mcpBaseUrl: string) => set({ mcpBaseUrl }),
|
||||
}));
|
||||
|
||||
@ -38,4 +38,6 @@ export type UtilityStoreType = {
|
||||
setHideGettingStartedProgress: (hideGettingStartedProgress: boolean) => void;
|
||||
allowCustomComponents: boolean;
|
||||
setAllowCustomComponents: (allowCustomComponents: boolean) => void;
|
||||
mcpBaseUrl: string;
|
||||
setMcpBaseUrl: (mcpBaseUrl: string) => void;
|
||||
};
|
||||
|
||||
@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user