Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 23 additions & 23 deletions src/lfx/src/lfx/_assets/component_index.json
Original file line number Diff line number Diff line change
Expand Up @@ -3253,7 +3253,7 @@
},
{
"name": "openai",
"version": "2.32.0"
"version": "2.33.0"
}
],
"total_dependencies": 5
Expand Down Expand Up @@ -54090,7 +54090,7 @@
},
{
"name": "couchbase",
"version": "4.6.0"
"version": "4.6.1"
}
],
"total_dependencies": 3
Expand Down Expand Up @@ -63838,7 +63838,7 @@
},
{
"name": "openai",
"version": "2.32.0"
"version": "2.33.0"
}
],
"total_dependencies": 6
Expand Down Expand Up @@ -68214,7 +68214,7 @@
},
{
"name": "googleapiclient",
"version": "2.194.0"
"version": "2.195.0"
}
],
"total_dependencies": 4
Expand Down Expand Up @@ -69517,7 +69517,7 @@
},
{
"name": "googleapiclient",
"version": "2.194.0"
"version": "2.195.0"
}
],
"total_dependencies": 7
Expand Down Expand Up @@ -72150,7 +72150,7 @@
},
{
"name": "git",
"version": "3.1.43"
"version": "3.1.47"
},
{
"name": "lfx",
Expand Down Expand Up @@ -72907,7 +72907,7 @@
},
{
"name": "googleapiclient",
"version": "2.194.0"
"version": "2.195.0"
},
{
"name": "langchain_core",
Expand Down Expand Up @@ -73322,7 +73322,7 @@
},
{
"name": "googleapiclient",
"version": "2.194.0"
"version": "2.195.0"
},
{
"name": "lfx",
Expand Down Expand Up @@ -81279,7 +81279,7 @@
},
{
"name": "langchain",
"version": "1.2.15"
"version": "1.2.17"
}
],
"total_dependencies": 3
Expand Down Expand Up @@ -86257,7 +86257,7 @@
},
{
"name": "openai",
"version": "2.32.0"
"version": "2.33.0"
}
],
"total_dependencies": 5
Expand Down Expand Up @@ -88527,7 +88527,7 @@
},
{
"name": "openai",
"version": "2.32.0"
"version": "2.33.0"
}
],
"total_dependencies": 4
Expand Down Expand Up @@ -92615,7 +92615,7 @@
"dependencies": [
{
"name": "toolguard",
"version": "0.2.16"
"version": "0.2.17"
},
{
"name": "lfx",
Expand Down Expand Up @@ -97547,7 +97547,7 @@
},
{
"name": "openai",
"version": "2.32.0"
"version": "2.33.0"
}
],
"total_dependencies": 4
Expand Down Expand Up @@ -113454,7 +113454,7 @@
},
{
"name": "langchain_google_vertexai",
"version": "3.2.2"
"version": "3.2.3"
},
{
"name": "google",
Expand Down Expand Up @@ -113838,7 +113838,7 @@
},
{
"name": "langchain_google_vertexai",
"version": "3.2.2"
"version": "3.2.3"
},
{
"name": "google",
Expand Down Expand Up @@ -114586,7 +114586,7 @@
},
{
"name": "openai",
"version": "2.32.0"
"version": "2.33.0"
}
],
"total_dependencies": 4
Expand Down Expand Up @@ -116011,7 +116011,7 @@
},
{
"name": "openai",
"version": "2.32.0"
"version": "2.33.0"
}
],
"total_dependencies": 6
Expand Down Expand Up @@ -116563,7 +116563,7 @@
},
{
"name": "googleapiclient",
"version": "2.194.0"
"version": "2.195.0"
},
{
"name": "lfx",
Expand Down Expand Up @@ -116752,7 +116752,7 @@
},
{
"name": "googleapiclient",
"version": "2.194.0"
"version": "2.195.0"
},
{
"name": "lfx",
Expand Down Expand Up @@ -117068,7 +117068,7 @@
},
{
"name": "googleapiclient",
"version": "2.194.0"
"version": "2.195.0"
},
{
"name": "lfx",
Expand Down Expand Up @@ -117467,7 +117467,7 @@
},
{
"name": "googleapiclient",
"version": "2.194.0"
"version": "2.195.0"
},
{
"name": "lfx",
Expand Down Expand Up @@ -117730,7 +117730,7 @@
"dependencies": [
{
"name": "googleapiclient",
"version": "2.194.0"
"version": "2.195.0"
},
{
"name": "pandas",
Expand Down Expand Up @@ -118101,6 +118101,6 @@
"num_components": 355,
"num_modules": 97
},
"sha256": "30e1190dc06f5782586a9628ddb7a5705cd1aa49d4d47e90503a5ddaf85290af",
"sha256": "ee87341f509679c77b84be79f1d595499b3bad014bfd967c0d0c0a3db85a27eb",
"version": "0.4.2"
}
111 changes: 65 additions & 46 deletions src/lfx/src/lfx/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import contextlib
import contextvars
import copy
import re
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Any
Expand Down Expand Up @@ -126,8 +127,12 @@ def _get_client() -> LangflowClient:


def _set_client(client: LangflowClient) -> None:
global _shared_client # noqa: PLW0603
global _shared_client, _shared_registry # noqa: PLW0603
_client_var.set(client)
# Invalidate the shared registry when the client changes, since the
# registry was loaded against the old client's server.
if _shared_client is not client:
_shared_registry = None
_shared_client = client


Expand Down Expand Up @@ -228,14 +233,17 @@ async def login(username: str, password: str, server_url: str | None = None) ->
password: Langflow password.
server_url: Server URL (defaults to LANGFLOW_SERVER_URL env var or http://localhost:7860).
"""
old_client = _client_var.get() or _shared_client
# Only close the client owned by this session (contextvar). The shared
# fallback is still overwritten by _set_client below, but we avoid
# closing it here since another session may be using it.
old_client = _client_var.get()
if old_client is not None:
await old_client.close()
client = LangflowClient(server_url=server_url)
_set_client(client)
# Clear only this session's cached registry; leave _shared_registry
# intact so other sessions are not disrupted.
_registry_var.set(None)
global _shared_registry # noqa: PLW0603
_shared_registry = None
await client.login(username, password)
return {"status": "authenticated", "server_url": client.server_url}

Expand Down Expand Up @@ -403,9 +411,14 @@ async def create_flow_from_spec(spec: str, *, validate: bool = True) -> dict[str
id_map[edge["target_id"]],
edge["target_input"],
)
# Validate by building the graph server-side
# Validate by building the graph server-side and waiting for results
if validate:
await build_flow(flow_id)
validation = await validate_flow(flow_id)
if not validation.get("valid", False):
errors = validation.get("errors", [])
detail = "; ".join(e.get("error", "Unknown") for e in errors)
msg = f"Flow validation failed: {detail}"
raise RuntimeError(msg)
Comment on lines +417 to +421

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Validation failure detail can be lost when errors is empty.

If validate_flow returns {valid: False, error: ... , errors: []}, the raised message becomes Flow validation failed: with no detail. Add a fallback to validation["error"].

Suggested fix
-                errors = validation.get("errors", [])
-                detail = "; ".join(e.get("error", "Unknown") for e in errors)
+                errors = validation.get("errors", [])
+                detail = "; ".join(
+                    e.get("error", "Unknown") for e in errors if isinstance(e, dict)
+                )
+                if not detail:
+                    detail = str(validation.get("error", "Unknown"))
                 msg = f"Flow validation failed: {detail}"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lfx/src/lfx/mcp/server.py` around lines 417 - 421, The RuntimeError
message when validation fails can be empty if validation["errors"] is an empty
list; update the failure path in the block that checks validation (the code
using validation.get("valid", False), errors = validation.get("errors", []),
detail = "; ".join(...), msg = f"Flow validation failed: {detail}", raise
RuntimeError(msg)) to fallback to validation.get("error") or a default message
when errors is empty—build detail from errors if present, otherwise use
validation.get("error", "Unknown validation error") before raising the
RuntimeError.

except Exception:
# Signal settle so the UI banner doesn't hang
with contextlib.suppress(Exception):
Expand Down Expand Up @@ -1220,54 +1233,60 @@ async def update_flow_from_spec(flow_id: str, spec: str) -> dict[str, Any]:
spec: Text spec in the same format as create_flow_from_spec.
"""
parsed = parse_flow_spec(spec)
registry = await _get_registry()

validate_spec_references(parsed)

# Build new flow data from spec
flow = empty_flow(
name=parsed.get("name", "Untitled Flow"),
description=parsed.get("description", ""),
)
# Clear the existing flow's nodes and edges, then rebuild using the same
# server-side tools as create_flow_from_spec so dynamic template refresh,
# prompt variable creation, and component_as_tool auto-enable all work.
# Validation is not performed here; callers can use validate_flow explicitly.
flow = await _get_flow(flow_id)
original_data = copy.deepcopy(flow["data"])
flow["data"]["nodes"] = []
flow["data"]["edges"] = []
patch_data: dict[str, Any] = {"data": flow["data"]}
if parsed.get("name"):
patch_data["name"] = parsed["name"]
if parsed.get("description"):
patch_data["description"] = parsed["description"]
await _get_client().patch(f"/flows/{flow_id}", json_data=patch_data)
Comment on lines +1328 to +1332

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Rollback is incomplete for flow metadata (name/description).

On Line 1248-Line 1251 you may patch name/description, but on failure Line 1282 restores only data. A failed update can still leave renamed/edited metadata. Also, truthy checks block intentional empty-string updates.

Suggested fix
-    patch_data: dict[str, Any] = {"data": flow["data"]}
-    if parsed.get("name"):
-        patch_data["name"] = parsed["name"]
-    if parsed.get("description"):
-        patch_data["description"] = parsed["description"]
+    original_name = flow.get("name")
+    original_description = flow.get("description", "")
+    patch_data: dict[str, Any] = {"data": flow["data"]}
+    if "name" in parsed:
+        patch_data["name"] = parsed["name"]
+    if "description" in parsed:
+        patch_data["description"] = parsed["description"]
@@
-        with contextlib.suppress(Exception):
-            await _get_client().patch(f"/flows/{flow_id}", json_data={"data": original_data})
+        with contextlib.suppress(Exception):
+            rollback_data: dict[str, Any] = {"data": original_data}
+            if original_name is not None:
+                rollback_data["name"] = original_name
+            rollback_data["description"] = original_description
+            await _get_client().patch(f"/flows/{flow_id}", json_data=rollback_data)

Also applies to: 1278-1283

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lfx/src/lfx/mcp/server.py` around lines 1248 - 1252, The patch currently
only adds name/description to patch_data when
parsed.get("name")/parsed.get("description") is truthy (which blocks intentional
empty-string updates) and the rollback only restores `data`, leaving any changed
`name`/`description` persisted; modify the update and rollback logic around the
`_get_client().patch(f"/flows/{flow_id}", json_data=patch_data)` call so that
you include name and description when the keys exist in `parsed` (use explicit
key presence checks like `if "name" in parsed`/`if "description" in parsed` to
allow empty strings) and ensure the rollback restores original metadata by
PATCHing the original `name`/`description` back (include those fields from the
original `data` in the rollback patch).


id_map: dict[str, str] = {}
for node in parsed["nodes"]:
result = fb_add_component(flow, node["type"], registry)
id_map[node["id"]] = result["id"]

for spec_id, params in parsed.get("config", {}).items():
fb_configure(flow, id_map[spec_id], params)

for edge in parsed["edges"]:
fb_add_connection(
flow,
id_map[edge["source_id"]],
edge["source_output"],
id_map[edge["target_id"]],
edge["target_input"],
)
try:
# Add components via server (gets live templates)
id_map: dict[str, str] = {}
for node in parsed["nodes"]:
result = await add_component(flow_id, node["type"])
id_map[node["id"]] = result["id"]

layout_flow(flow)
# Configure via server (handles real_time_refresh and tool_mode)
for spec_id, params in parsed.get("config", {}).items():
await configure_component(flow_id, id_map[spec_id], params)

# Patch the existing flow with new data
patch_data: dict[str, Any] = {
"data": flow["data"],
"description": parsed.get("description", ""),
}
if parsed.get("name"):
patch_data["name"] = parsed["name"]
# Create dynamic template variable fields on Prompt components
await _create_prompt_template_vars(flow_id, parsed, id_map)

await _get_client().patch(f"/flows/{flow_id}", json_data=patch_data)
await _get_client().post_event(flow_id, "flow_updated", "Updated flow from spec")
# Connect via server (handles component_as_tool auto-enable)
for edge in parsed["edges"]:
await connect_components(
flow_id,
id_map[edge["source_id"]],
edge["source_output"],
id_map[edge["target_id"]],
edge["target_input"],
)
except Exception:
# Restore the original flow data so the user's flow is not destroyed
with contextlib.suppress(Exception):
await _get_client().post_event(flow_id, "flow_settled", "Update failed, restoring")
with contextlib.suppress(Exception):
await _get_client().patch(f"/flows/{flow_id}", json_data={"data": original_data})
raise

return {
"id": flow_id,
"name": parsed.get("name", ""),
"node_count": len(flow["data"]["nodes"]),
"edge_count": len(flow["data"]["edges"]),
"node_id_map": id_map,
"spec_summary": fb_spec_summary(flow),
}
await _get_client().post_event(flow_id, "flow_settled", "Updated flow from spec")

info = await get_flow_info(flow_id)
info["node_id_map"] = id_map
return info


async def _set_frozen(flow_id: str, component_id: str, *, frozen: bool) -> dict[str, str]:
Expand Down
Loading
Loading