fix(mcp): scope tool calls and surface failures on the execution plane (#14522)
* fix(mcp): scope tool calls to project and exposure
* fix(mcp): report excluded flows in tools/list
* [autofix.ci] apply automated fixes
* feat(mcp): swap flow credentials for variable refs
* fix(mcp): close credential scrub bypasses
* fix(mcp): keep scrub correct across a lock retry
* fix(mcp): let a lock error retry, not lose the key
A contended write inside the scrub was swallowed and treated as a verdict
on that variable: the literal was restored and the flow saved the secret
in plaintext with a 200. Re-raise database lock errors so the caller's
retry re-runs the whole operation, and cover it with a PATCH that forces
real contention — the test only passes if the credential survives the
rollback and the flow ends up referencing a variable that exists.
* fix(mcp): resolve target URL from db globals only
request_variables on a run carry the caller's X-Langflow-Global-Var-*
headers. Feeding them to the URL resolver let whoever calls a flow choose
where it connects, and the resolved credential headers travel to that
destination — SSRF validation rejects internal targets, not an arbitrary
external one.
Split the provenance: update_tools takes url_variables, resolved only
from the database-backed globals, while headers keep resolving from the
request set, which is a documented feature. The component loads the DB
set whenever the config references variables and passes it separately;
the v2 server check already read its variables from the table.
Verified live: a server whose URL is {{HOST}}/... resolves and lists its
tool from the DB variable, and the same request carrying
X-Langflow-Global-Var-HOST: http://attacker.invalid still reaches the
legitimate target.
* fix(mcp): apply rotations and resolve env safely
Three findings from the PR review, all in the credential scrub.
Rotating a key through the node was a silent no-op. The variable name
does not depend on the value, an existing variable and an existing
mcp_server row were both left alone, and the row wins at runtime — so
every edit after the first was dropped and the old credential kept being
used. A literal arriving in a flow is the user asking for a change, so it
now updates both; only the secret-bearing maps of the stored config are
replaced, leaving the URL, mode and args the user maintains there.
env was never resolved, so a stdio server was handed the MCP_ reference
as its key while the module docstring promised portability. Resolve it
from the database-backed globals only — never from request_variables,
since env is handed to a spawned process.
Every non-empty header became a Credential global variable, so
accept: application/json cluttered the variable list. Skip an allowlist
of headers that are never secrets. Deliberately not a heuristic for
'looks secret': guessing which values are sensitive fails open into a
leak, while these specific names cannot be one.
Verified live: a rotated key reaches the stored row, accept stays
literal while the key beside it becomes a reference, and the exported
flow still runs under lfx serve — rejected with HTTP 401 on a wrong
credential, past the MCP node on the right one.
* [autofix.ci] apply automated fixes
* fix(mcp): support LANGFLOW_MCP_BASE_URL override for multi-pod deployments
- Add _get_project_base_url() helper that returns the configured mcp_base_url
verbatim when set, bypassing host/port derivation and WSL rewriting
- Fix type narrowing in get_project_streamable_http_url and get_project_sse_url:
guard on so port is narrowed to int before
reaching get_url_by_os(host: str, port: int, ...)
- Expand test coverage: add SSE fallback test and whitespace-only mcp_base_url
edge case that were previously untested
- Update mcp_base_url docstring to document the multi-pod bind-vs-advertise
requirement
(cherry picked from commit cd2632d73c6689d8c930e509527b9e0460abc8b7)
* fix(mcp): keep composer upstream on the local URL
LANGFLOW_MCP_BASE_URL names where clients connect. MCP Composer is a
subprocess of this process and registers the project endpoint as an
upstream member server it dials itself, so the cherry-picked override
sent pod-local traffic out through the ingress and back — behind a load
balancer that lands on a different pod, and behind TLS/auth termination
it is refused outright.
Split the two meanings: get_project_streamable_http_url stays the
advertised URL and keeps the override, while the composer registration
paths use new local builders that always derive from the bind host and
port.
Refs LE-2175
* [autofix.ci] apply automated fixes
* fix(mcp): stop leaking owner errors and credentials
Four findings from the second review pass, all reproduced before fixing.
A public project executes as its owning principal so the flow can read the
owner's variables, and the disclosure check compared that execution
principal to the flow owner — which is the same person for every
anonymous caller. Live against auth_type="none", an unauthenticated
tools/call returned the raw component error and its file path; it now
returns the generic failure while the owner still gets the detail.
Disclosure now keys off the principal that actually presented a
credential, defaulting to unset so a path that establishes no caller
loses the privilege instead of inheriting the owner's.
The gate that decides whether to load global variables looked at headers
and url but not env, while the scrub rewrites env values to MCP_ names
just like headers. A stdio server whose only secret lived in env handed
the subprocess the variable name in place of the credential.
Failure messages appended the target URL and the cause verbatim.
raise_for_status builds a message containing the full request URL, so a
401 arrived carrying the credential it had just rejected — the userinfo
and query stripping applied to the target we format ourselves was not
enough. Both describers now redact every URL they emit, including the
ones inside a cause they did not compose.
A variable that could not be created put the literal back into flow.data
and answered 200. That traded a leak for a working flow, but the caller
was never told the credential had landed in an unencrypted column that
travels through export, share and version history. Refuse the write
instead: a control that silently turns itself off is worse than one that
fails. Verified over HTTP that the 500 survives the routes' broad
exception handling on both the single and batch create paths.
Refs LE-2175
* fix(lfx): redact credential URLs from serve tracebacks
Found while smoke-testing lfx serve against a 401 endpoint. The message
was clean, but the response also carries the full traceback, and inside
it httpx's own line read: Client error '401 Unauthorized' for url
'http://user:hunter2@127.0.0.1:9411/mcp?api_key=supersecret'. That body
goes to the caller and the same text goes to the log aggregator.
Redacting only inside the MCP describers was too narrow, so the URL
reduction moves to lfx.utils.url_redaction and the serve error path
applies it to the traceback, the message and the log line.
Refs LE-2175
* component index fix
* test(mcp): pin who error details are disclosed to
The handler used to read ownership off the principal the flow executes
as. Establishing a caller is now part of its contract, and the harness
only set the execution principal, so the owner case read as anonymous
and the disclosure assertion failed.
Set the caller in the harness, and add the two cases the change exists
for: no caller at all (a public project, which executes as its owner for
an anonymous caller) and a caller who is somebody else. Both must get
the generic failure.
Refs LE-2175
* fix(mcp): stop a flow import rewriting a shared credential
Reported by Rafael Gil on LE-2175 and reproduced here byte for byte:
register ui_svc with Bearer UI-KEY-1, import a flow whose inline config
carries UI-KEY-2 without opening it, and the stored row comes back
holding UI-KEY-2 — after which an untouched flow A sends the imported
key on the wire.
mcp_server rows are keyed on (user, name) and shared by every flow of
that user, so rotating one on any write let a single import re-point all
of them, unrecoverably: the encrypted column is overwritten in place and
nothing in the response, the log or the manager reports it. A flow JSON
from a third party could plant a credential on a server name that
already exists in the recipient's instance.
This is the cost I named when I made rotation win in 81e3de2 and shipped
anyway. Rotation is right only where a literal really is the user typing
a key, so it is now scoped to update_flow and, within it, to servers the
stored flow already referenced. Import, batch create, upload and create
leave an existing row alone, which is what the module docstring always
claimed. Saving a freshly imported flow cannot adopt its credential
either, because the flow was not bound to that server before the write.
Also closes the second shape: a config carrying the documented
global-variable-name form replaced the stored literal with the string
"x-api-key", which the manager then rendered as a legitimate binding.
Refs LE-2175
* [autofix.ci] apply automated fixes
---------
Co-authored-by: Janardan Singh Kavia <janardankavia@ibm.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Janardan S Kavia <janardanskavia@Janardans-MacBook-Pro.local> C
Cristhian Zanforlin Lousa committed
82adf5c36ed8fb4f57daa90a569a775462997bfb
Parent: 0237127
Committed by GitHub <noreply@github.com>
on 8/17/2026, 1:07:46 PM