Skip to content

Commit de2abbe

Browse files
octo-patcherichare
andcommitted
fix: propagate x-api-key and authorization headers to nested MCP calls (#12541)
* fix: propagate x-api-key and authorization headers to nested MCP calls (fixes #12529) When Langflow is used as an MCP server containing flows with nested MCP components, authentication headers like x-api-key were silently dropped because extract_global_variables_from_headers() only captured headers with the X-LANGFLOW-GLOBAL-VAR-* prefix. Add _AUTH_HEADERS_TO_PROPAGATE to also capture x-api-key and authorization under their lowercase header names. These values are stored in the request_variables context, making them available for resolution in nested MCP server configs. Users can now reference them in their server headers config as {x-api-key: x-api-key} to propagate the incoming key. * fix: remove duplicate verify_public_flow_and_get_user from core.py The duplicate function in core.py referenced undefined names (uuid, session_scope) and shadowed the canonical implementation in flow_utils.py, which is the one re-exported from __init__.py and has the fuller signature including authenticated_user_id. Removing the dead copy resolves the F821 ruff errors. * Tighten scope of fix * Update .secrets.baseline * Clean up test locations * Update .secrets.baseline --------- Co-authored-by: Eric Hare <ericrhare@gmail.com>
1 parent 5b59b6d commit de2abbe

4 files changed

Lines changed: 117 additions & 20 deletions

File tree

.secrets.baseline

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1701,16 +1701,6 @@
17011701
"is_secret": false
17021702
}
17031703
],
1704-
"src/backend/base/langflow/api/utils/core.py": [
1705-
{
1706-
"type": "Secret Keyword",
1707-
"filename": "src/backend/base/langflow/api/utils/core.py",
1708-
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
1709-
"is_verified": false,
1710-
"line_number": 413,
1711-
"is_secret": false
1712-
}
1713-
],
17141704
"src/backend/base/langflow/initial_setup/starter_projects/Basic Prompt Chaining.json": [
17151705
{
17161706
"type": "Hex High Entropy String",
@@ -8245,9 +8235,13 @@
82458235
}
82468236
]
82478237
},
8238+
<<<<<<< HEAD
82488239
<<<<<<< HEAD
82498240
"generated_at": "2026-04-23T21:12:19Z"
82508241
=======
82518242
"generated_at": "2026-04-15T15:25:36Z"
82528243
>>>>>>> f748738c74 (fix: MCP Auth Error on restart / swapping auth (#12715))
8244+
=======
8245+
"generated_at": "2026-04-21T23:47:54Z"
8246+
>>>>>>> 3ea28ed902 (fix: propagate x-api-key and authorization headers to nested MCP calls (#12541))
82538247
}

src/backend/base/langflow/api/utils/core.py

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -398,19 +398,44 @@ def custom_params(
398398
return Params(page=page or MIN_PAGE_SIZE, size=size or MAX_PAGE_SIZE)
399399

400400

401-
def extract_global_variables_from_headers(headers) -> dict[str, str]:
402-
"""Extract global variables from HTTP headers with prefix X-LANGFLOW-GLOBAL-VAR-*.
401+
# Well-known authentication headers that can be propagated to nested MCP calls
402+
# when ``include_auth_headers=True`` is passed. These are stored under their
403+
# lowercase header names so that nested server configs can reference them
404+
# directly, e.g. ``{"x-api-key": "x-api-key"}`` in the MCP server headers config.
405+
_AUTH_HEADERS_TO_PROPAGATE = frozenset({"x-api-key", "authorization"})
406+
407+
408+
def extract_global_variables_from_headers(headers, *, include_auth_headers: bool = False) -> dict[str, str]:
409+
"""Extract global variables from HTTP headers.
410+
411+
By default, only headers with the ``X-LANGFLOW-GLOBAL-VAR-*`` prefix are
412+
extracted. When ``include_auth_headers=True``, the well-known authentication
413+
headers ``x-api-key`` and ``authorization`` are additionally captured under
414+
their lowercase names so that nested MCP server configs can reference them
415+
directly (e.g. ``{"x-api-key": "x-api-key"}``).
416+
417+
SECURITY NOTE: Only pass ``include_auth_headers=True`` from MCP call sites
418+
(see ``api/v1/mcp_projects.py``). On non-MCP routes such as ``/run`` and
419+
``/workflow``, ``x-api-key`` is Langflow's own authentication key — exposing
420+
it in ``request_variables`` would make it readable by any component that
421+
reads the graph context.
403422
404423
Args:
405-
headers: HTTP headers object (e.g., from FastAPI Request.headers)
424+
headers: HTTP headers object (e.g., from FastAPI Request.headers).
425+
include_auth_headers: When True, also extract well-known authentication
426+
headers (``x-api-key``, ``authorization``) under their lowercase
427+
names. Should only be set by MCP request handlers that need to
428+
propagate these values to nested MCP calls.
406429
407430
Returns:
408-
Dictionary mapping variable names (uppercase) to their values
431+
Dictionary mapping variable names to their values.
409432
410433
Example:
411-
headers = {"X-LANGFLOW-GLOBAL-VAR-API-KEY": "secret", "Content-Type": "application/json"}
412-
result = extract_global_variables_from_headers(headers)
413-
# Returns: {"API_KEY": "secret"}
434+
headers = {"X-LANGFLOW-GLOBAL-VAR-API-KEY": "secret", "x-api-key": "mykey"}
435+
extract_global_variables_from_headers(headers)
436+
# Returns: {"API-KEY": "secret"}
437+
extract_global_variables_from_headers(headers, include_auth_headers=True)
438+
# Returns: {"API-KEY": "secret", "x-api-key": "mykey"}
414439
"""
415440
variables: dict[str, str] = {}
416441

@@ -420,6 +445,8 @@ def extract_global_variables_from_headers(headers) -> dict[str, str]:
420445
if header_lower.startswith(LANGFLOW_GLOBAL_VAR_HEADER_PREFIX):
421446
var_name = header_lower[len(LANGFLOW_GLOBAL_VAR_HEADER_PREFIX) :].upper()
422447
variables[var_name] = header_value
448+
elif include_auth_headers and header_lower in _AUTH_HEADERS_TO_PROPAGATE:
449+
variables[header_lower] = header_value
423450
except Exception as exc: # noqa: BLE001
424451
# Log the error but don't raise - we want to continue execution
425452
logger.exception("Failed to extract global variables from headers: %s", exc)

src/backend/base/langflow/api/v1/mcp_projects.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,7 @@ async def handle_project_sse(
345345

346346
user_token = current_user_ctx.set(current_user)
347347
project_token = current_project_ctx.set(project_id)
348-
variables = extract_global_variables_from_headers(request.headers)
348+
variables = extract_global_variables_from_headers(request.headers, include_auth_headers=True)
349349
req_vars_token = current_request_variables_ctx.set(variables or None)
350350

351351
try:
@@ -386,7 +386,7 @@ async def _handle_project_sse_messages(
386386
"""Handle POST messages for a project-specific MCP server using SSE transport."""
387387
user_token = current_user_ctx.set(current_user)
388388
project_token = current_project_ctx.set(project_id)
389-
variables = extract_global_variables_from_headers(request.headers)
389+
variables = extract_global_variables_from_headers(request.headers, include_auth_headers=True)
390390
req_vars_token = current_request_variables_ctx.set(variables or None)
391391

392392
try:
@@ -443,7 +443,7 @@ async def _dispatch_project_streamable_http(
443443

444444
user_token = current_user_ctx.set(current_user)
445445
project_token = current_project_ctx.set(project_id)
446-
variables = extract_global_variables_from_headers(request.headers)
446+
variables = extract_global_variables_from_headers(request.headers, include_auth_headers=True)
447447
request_vars_token = current_request_variables_ctx.set(variables or None)
448448

449449
try:

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

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from types import SimpleNamespace
22

33
import pytest
4+
from langflow.api.utils.core import extract_global_variables_from_headers
45
from langflow.api.v1 import mcp_utils
56
from lfx.interface.components import component_cache
67

@@ -292,3 +293,78 @@ async def test_handle_list_tools_requires_current_user_on_global_server(monkeypa
292293
# No user context set — must return empty.
293294
tools = await mcp_utils.handle_list_tools()
294295
assert tools == []
296+
class TestExtractGlobalVariablesFromHeaders:
297+
"""Unit tests for ``extract_global_variables_from_headers``.
298+
299+
Covers the MCP auth-header propagation fix (issue #12529): ``x-api-key``
300+
and ``authorization`` should be captured under their lowercase names when
301+
(and only when) ``include_auth_headers=True`` is passed. The default
302+
behavior must remain backwards-compatible for non-MCP routes, where
303+
``x-api-key`` is Langflow's own auth key and must not leak into the graph
304+
context.
305+
"""
306+
307+
def test_langflow_global_var_prefix_still_extracted(self):
308+
"""Regression guard: ``X-LANGFLOW-GLOBAL-VAR-*`` extraction is preserved."""
309+
headers = {
310+
"X-LANGFLOW-GLOBAL-VAR-API-KEY": "secret-value",
311+
"X-LANGFLOW-GLOBAL-VAR-DB-URL": "postgres://host/db",
312+
"Content-Type": "application/json",
313+
}
314+
315+
result = extract_global_variables_from_headers(headers)
316+
317+
assert result == {"API-KEY": "secret-value", "DB-URL": "postgres://host/db"}
318+
319+
def test_auth_headers_not_extracted_by_default(self):
320+
"""Non-MCP call sites: ``x-api-key`` / ``authorization`` must not leak through."""
321+
headers = {
322+
"x-api-key": "langflow-auth-key",
323+
"authorization": "Bearer token",
324+
"X-LANGFLOW-GLOBAL-VAR-MY-VAR": "value",
325+
}
326+
327+
result = extract_global_variables_from_headers(headers)
328+
329+
assert "x-api-key" not in result
330+
assert "authorization" not in result
331+
assert result == {"MY-VAR": "value"}
332+
333+
def test_auth_headers_extracted_under_lowercase_when_opted_in(self):
334+
"""MCP call sites: lowercase auth headers are captured when opted in."""
335+
headers = {
336+
"x-api-key": "api-key-value",
337+
"authorization": "Bearer jwt-token",
338+
}
339+
340+
result = extract_global_variables_from_headers(headers, include_auth_headers=True)
341+
342+
assert result == {"x-api-key": "api-key-value", "authorization": "Bearer jwt-token"}
343+
344+
def test_auth_header_matching_is_case_insensitive(self):
345+
"""Headers with mixed or uppercase casing still match (e.g. ``X-Api-Key``, ``AUTHORIZATION``)."""
346+
headers = {
347+
"X-Api-Key": "mixed-case-value",
348+
"AUTHORIZATION": "Bearer UPPER",
349+
}
350+
351+
result = extract_global_variables_from_headers(headers, include_auth_headers=True)
352+
353+
assert result == {"x-api-key": "mixed-case-value", "authorization": "Bearer UPPER"}
354+
355+
def test_both_categories_extracted_together(self):
356+
"""``X-LANGFLOW-GLOBAL-VAR-*`` and auth headers coexist when opted in."""
357+
headers = {
358+
"X-LANGFLOW-GLOBAL-VAR-API-KEY": "global-secret",
359+
"x-api-key": "incoming-mcp-key",
360+
"Authorization": "Bearer mcp-token",
361+
"Content-Type": "application/json",
362+
}
363+
364+
result = extract_global_variables_from_headers(headers, include_auth_headers=True)
365+
366+
assert result == {
367+
"API-KEY": "global-secret",
368+
"x-api-key": "incoming-mcp-key",
369+
"authorization": "Bearer mcp-token",
370+
}

0 commit comments

Comments
 (0)