Skip to content

LCORE-1313 Add automatic header propagation to MCP servers using an allowlist#1222

Merged
tisnik merged 3 commits intolightspeed-core:mainfrom
max-svistunov:lcore-1313-mcp-header-allowlist-propagation
Feb 25, 2026
Merged

LCORE-1313 Add automatic header propagation to MCP servers using an allowlist#1222
tisnik merged 3 commits intolightspeed-core:mainfrom
max-svistunov:lcore-1313-mcp-header-allowlist-propagation

Conversation

@max-svistunov
Copy link
Contributor

@max-svistunov max-svistunov commented Feb 25, 2026

Description

Add automatic header propagation to MCP servers via allowlist configuration.

mcp_servers:
  - name: "rbac"
    url: "http://rbac-service:8080"
    headers:
      - x-rh-identity
      - x-rh-insights-request-id

Type of change

  • Refactor
  • New feature
  • Bug fix
  • CVE fix
  • Optimization
  • Documentation Update
  • Configuration Update
  • Bump-up service version
  • Bump-up dependent library
  • Bump-up library or tool used for development (does not change the final image)
  • CI configuration change
  • Konflux configuration change
  • Unit tests improvement
  • Integration tests improvement
  • End to end tests improvement
  • Benchmarks improvement

Tools used to create PR

Identify any AI code assistants used in this PR (for transparency and review context)

  • Assisted-by: Claude Opus 4.6
  • Generated by: Claude Opus 4.6

Related Tickets & Documents

  • Related Issue # LCORE-1313
  • Closes # LCORE-1313

Checklist before requesting a review

  • I have performed a self-review of my code.
  • PR has passed all pre-merge test jobs.
  • If it is a core feature, I have added thorough tests.

Testing

  1. Start:
  • MCP mock server on :3000
  • LlamaStack on :8321 (run-ci.yaml)
  • Lightspeed on :8080 with headers: [x-rh-identity, x-rh-insights-request-id] for the
    mock server
  1. POST /v1/query with both headers set
  2. GET /debug/requests on the mock — verify both headers present on all 3 MCP protocol calls
  3. POST /v1/query with an extra non-allowlisted header (x-should-not-forward)
  4. GET /debug/headers on the mock — verify the non-allowlisted header is absent

Summary by CodeRabbit

  • New Features

    • Automatic header propagation: forward selected incoming HTTP headers to configured MCP servers (case-insensitive, additive with existing auth, precedence rules) and a new endpoint to query client options.
  • Documentation

    • New how-to section, examples, use case, and updated MCP authentication table showing header-propagation behavior and server-skipping rules.
  • Tests

    • Added tests covering propagation, case-insensitivity, missing headers, duplicates, and interactions with other auth methods.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 25, 2026

Walkthrough

Adds automatic header propagation: a new per-MCP-server headers allowlist is configured, request.headers are threaded through endpoints and utilities, extracted case-insensitively, and merged additively with existing authorization/header methods for MCP tool construction.

Changes

Cohort / File(s) Summary
Docs & Examples
README.md, docs/config.md, examples/lightspeed-stack-mcp-servers.yaml
Documented new headers field, behavior (case-insensitive matching, additive merging, skipping missing headers), examples and client-options endpoint output.
Config Model
src/models/config.py
Added headers: list[str] to ModelContextProtocolServer with validator preventing empty or case-insensitive duplicate names.
Header Extraction Utility
src/utils/mcp_headers.py
New extract_propagated_headers(mcp_server, request_headers) to case-insensitively match allowlisted headers and return present header name/value pairs preserving configured casing.
Responses / MCP tools
src/utils/responses.py
Threaded optional request_headers through prepare_tools(), prepare_responses_params(), and get_mcp_tools(); merged extracted propagated headers into MCP tool header assembly without overwriting authorization_headers.
Endpoints / Execution Path
src/app/endpoints/a2a.py, src/app/endpoints/query.py, src/app/endpoints/rlsapi_v1.py, src/app/endpoints/streaming_query.py
Thread request.headers into app creation, A2A executor, and responses preparation so allowlisted headers flow from incoming requests to MCP tools.
Tests
tests/unit/utils/test_mcp_headers.py, tests/unit/utils/test_responses.py, tests/unit/models/config/*, tests/unit/app/endpoints/*
Added unit tests covering extraction (case-insensitivity, missing headers, empties, duplicates) and integration tests ensuring propagated headers merge correctly and do not override authorization headers.

Sequence Diagram

sequenceDiagram
    participant Client as HTTP Client
    participant Endpoint as Endpoint Handler
    participant Utils as Response/Tool Utils
    participant MCP as MCP Server

    Client->>Endpoint: HTTP request (headers: x-rh-identity, x-request-id)
    Endpoint->>Endpoint: capture request.headers
    Endpoint->>Utils: prepare_responses_params(request_headers=...)
    Utils->>Utils: extract_propagated_headers(mcp_server, request_headers)
    Utils->>Utils: merge propagated headers with authorization_headers & MCP-HEADERS
    Utils->>MCP: build MCP tool definitions with merged headers
    MCP-->>Utils: tool config ready
    Utils-->>Endpoint: prepared responses (with MCP tools)
    Endpoint-->>Client: response
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related PRs

Suggested labels

enhancement

Suggested reviewers

  • tisnik
  • umago
  • ldjebran
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main change: adding automatic header propagation to MCP servers using an allowlist. It directly reflects the core feature and configuration changes throughout the changeset.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@max-svistunov
Copy link
Contributor Author

@tisnik Could you PTAL?

Btw, in the snippet you referenced the principle was the same (extract headers & forward to MCP servers), but it just hardcoded x-rh-identity and also basically replaced k8s auth case in the get_token_value fucntion, which we probably don't want to break.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (4)
src/utils/mcp_headers.py (1)

99-121: Case-insensitive lookup is one-directional; document the Starlette dependency or normalize both sides.

request_headers.get(header_name.lower()) only works because Starlette's Headers object stores all keys in lowercase. If request_headers is a plain dict with mixed-case keys (e.g., {"X-RH-Identity": "val"}), the lookup silently misses the header. The docstring claims case-insensitive matching but the function implicitly depends on the caller normalizing keys to lowercase.

Either add a note to the docstring clarifying this precondition, or make both sides truly case-insensitive:

♻️ Proposed fix — normalize both sides
-    propagated: dict[str, str] = {}
-    for header_name in mcp_server.headers:
-        value = request_headers.get(header_name.lower())
-        if value is not None:
-            propagated[header_name] = value
-    return propagated
+    # Build a lowercase-keyed view of request_headers for case-insensitive lookup
+    lower_request_headers = {k.lower(): v for k, v in request_headers.items()}
+    propagated: dict[str, str] = {}
+    for header_name in mcp_server.headers:
+        value = lower_request_headers.get(header_name.lower())
+        if value is not None:
+            propagated[header_name] = value
+    return propagated
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/mcp_headers.py` around lines 99 - 121, The function
extract_propagated_headers currently assumes request_headers keys are lowercased
(as in Starlette.Headers) so request_headers.get(header_name.lower()) can miss
mixed-case keys; make the lookup truly case-insensitive by normalizing
request_headers keys before iterating (e.g., build a mapping like
normalized_request = {k.lower(): v for k, v in request_headers.items()}) and
then use normalized_request.get(header_name.lower()) when populating propagated,
or alternatively update the docstring to explicitly state the precondition that
request_headers must be lowercased; refer to extract_propagated_headers,
mcp_server.headers and request_headers to locate the change.
tests/unit/utils/test_mcp_headers.py (1)

252-262: Add the inverse case-insensitive test — allowlist lowercase, request header mixed-case.

test_case_insensitive_lookup verifies allowlist-uppercase → request-lowercase. The inverse (allowlist-lowercase → request-uppercase/mixed plain dict) is not tested and would currently fail, which would help document the Starlette-dependency assumption noted in extract_propagated_headers.

def test_case_insensitive_lookup_uppercase_request_header(self) -> None:
    """Test direction: allowlist lowercase, request header has uppercase key (plain dict)."""
    server = ModelContextProtocolServer(
        name="rbac",
        url="http://rbac:8080",
        headers=["x-rh-identity"],
    )
    # NOTE: this test would currently fail with a plain dict because
    # request_headers.get("x-rh-identity") does not find "X-RH-Identity".
    # In production this is fine because Starlette always lowercases headers.
    request_headers = {"X-RH-Identity": "identity-value"}  # mixed-case plain dict
    result = extract_propagated_headers(server, request_headers)
    # Documents current behavior: relies on caller normalizing keys to lowercase
    assert result == {}  # or {"x-rh-identity": "identity-value"} after the fix
src/models/config.py (1)

525-537: Consider adding a @field_validator to reject empty strings and duplicates in headers.

The field accepts any list[str] without validation. Empty strings ("") would silently no-op during propagation, and duplicate entries are wasteful but undetected. As per coding guidelines, @field_validator should be used for custom validation in Pydantic configuration models.

🛡️ Proposed validator
     headers: list[str] = Field(
         default_factory=list,
         title="Propagated headers",
         description=(...),
     )
+
+    `@field_validator`("headers")
+    `@classmethod`
+    def validate_headers(cls, value: list[str]) -> list[str]:
+        """Validate propagated headers list: no empty strings, no duplicates."""
+        for h in value:
+            if not h.strip():
+                raise ValueError("Header names in 'headers' must not be empty or blank")
+        if len(value) != len({h.lower() for h in value}):
+            raise ValueError("Header names in 'headers' must be unique (case-insensitive)")
+        return value

As per coding guidelines: "Use @field_validator and @model_validator for custom validation in Pydantic configuration models."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/models/config.py` around lines 525 - 537, Add a Pydantic `@field_validator`
for the headers Field (the headers attribute) that inspects the provided
list[str], rejects/raises ValueError for any empty-string entries, and rejects
duplicates in a case-insensitive manner (e.g., by comparing lowercased values);
return the validated/normalized list (or raise on invalid input) so the model
never silently accepts "" or duplicate header names. Use the `@field_validator` on
"headers" and reference the headers Field in src/models/config.py; ensure the
validator signature accepts the raw list and returns the cleaned list or raises
ValueError with a clear message.
tests/unit/utils/test_responses.py (1)

590-617: Add a mixed-case precedence regression test.

Current coverage proves non-overwrite for same-case names, but it doesn’t cover Authorization (auth header) vs authorization (allowlist). Adding that case will lock in the precedence contract and catch case-sensitive merge regressions.

✅ Suggested test addition
+    `@pytest.mark.asyncio`
+    async def test_get_mcp_tools_auth_precedence_is_case_insensitive(
+        self, tmp_path: Path
+    ) -> None:
+        secret_file = tmp_path / "token.txt"
+        secret_file.write_text("secret-token")
+
+        servers = [
+            ModelContextProtocolServer(
+                name="rbac",
+                url="http://rbac:8080",
+                authorization_headers={"Authorization": str(secret_file)},
+                headers=["authorization"],  # same header, different case
+            ),
+        ]
+        request_headers = {"authorization": "request-auth-value"}
+
+        tools = await get_mcp_tools(
+            servers, token=None, mcp_headers=None, request_headers=request_headers
+        )
+
+        assert len(tools) == 1
+        assert tools[0]["headers"] == {"Authorization": "secret-token"}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/utils/test_responses.py` around lines 590 - 617, Add a regression
test to tests/unit/utils/test_responses.py that verifies case-mixed header
precedence: create a ModelContextProtocolServer with authorization_headers
containing "Authorization" (pointing to a token file) and headers allowlist
including "Authorization" and "x-rh-identity", then call get_mcp_tools with
request_headers containing lowercase "authorization" and "x-rh-identity"; assert
the resulting tool's headers keep the Authorization value from
authorization_headers (the file token) and include the propagated
"x-rh-identity" value from request_headers; name the test something like
test_get_mcp_tools_mixed_case_precedence and use the existing patterns (tmp_path
fixture, secret file, servers list) so it catches case-sensitive merge
regressions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/utils/responses.py`:
- Around line 434-440: The current loop in the mcp_server header propagation
uses a case-sensitive membership check which allows duplicate semantic headers
with different casing to coexist; change the check in the block that iterates
propagated headers (around extract_propagated_headers usage) to perform a
case-insensitive precedence test: compute a set of existing header names
normalized to lowercase from headers (and include any authorization_headers set
if present), then only add a propagated header when h_name.lower() is not in
that lowercase set; preserve the original propagated header casing when
inserting into headers but use the lowercase membership test to enforce "auth
headers win" precedence.

---

Nitpick comments:
In `@src/models/config.py`:
- Around line 525-537: Add a Pydantic `@field_validator` for the headers Field
(the headers attribute) that inspects the provided list[str], rejects/raises
ValueError for any empty-string entries, and rejects duplicates in a
case-insensitive manner (e.g., by comparing lowercased values); return the
validated/normalized list (or raise on invalid input) so the model never
silently accepts "" or duplicate header names. Use the `@field_validator` on
"headers" and reference the headers Field in src/models/config.py; ensure the
validator signature accepts the raw list and returns the cleaned list or raises
ValueError with a clear message.

In `@src/utils/mcp_headers.py`:
- Around line 99-121: The function extract_propagated_headers currently assumes
request_headers keys are lowercased (as in Starlette.Headers) so
request_headers.get(header_name.lower()) can miss mixed-case keys; make the
lookup truly case-insensitive by normalizing request_headers keys before
iterating (e.g., build a mapping like normalized_request = {k.lower(): v for k,
v in request_headers.items()}) and then use
normalized_request.get(header_name.lower()) when populating propagated, or
alternatively update the docstring to explicitly state the precondition that
request_headers must be lowercased; refer to extract_propagated_headers,
mcp_server.headers and request_headers to locate the change.

In `@tests/unit/utils/test_responses.py`:
- Around line 590-617: Add a regression test to
tests/unit/utils/test_responses.py that verifies case-mixed header precedence:
create a ModelContextProtocolServer with authorization_headers containing
"Authorization" (pointing to a token file) and headers allowlist including
"Authorization" and "x-rh-identity", then call get_mcp_tools with
request_headers containing lowercase "authorization" and "x-rh-identity"; assert
the resulting tool's headers keep the Authorization value from
authorization_headers (the file token) and include the propagated
"x-rh-identity" value from request_headers; name the test something like
test_get_mcp_tools_mixed_case_precedence and use the existing patterns (tmp_path
fixture, secret file, servers list) so it catches case-sensitive merge
regressions.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9af1c06 and 5d3d1d7.

📒 Files selected for processing (12)
  • README.md
  • docs/config.md
  • examples/lightspeed-stack-mcp-servers.yaml
  • src/app/endpoints/a2a.py
  • src/app/endpoints/query.py
  • src/app/endpoints/rlsapi_v1.py
  • src/app/endpoints/streaming_query.py
  • src/models/config.py
  • src/utils/mcp_headers.py
  • src/utils/responses.py
  • tests/unit/utils/test_mcp_headers.py
  • tests/unit/utils/test_responses.py

@max-svistunov max-svistunov force-pushed the lcore-1313-mcp-header-allowlist-propagation branch from 5d3d1d7 to 256b520 Compare February 25, 2026 16:03
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
tests/unit/utils/test_responses.py (1)

624-795: Optional: reduce repeated setup in the new propagation tests.

The new cases are solid; extracting a tiny server/config helper (or parameterizing common setup) would make maintenance easier as scenarios grow.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/utils/test_responses.py` around lines 624 - 795, Several tests
repeat the same MCP server/config setup; extract a small helper or fixture
(e.g., a function like make_mcp_server or a pytest fixture create_mcp_config) to
construct ModelContextProtocolServer instances and patch
utils.responses.configuration once; update tests such as
test_get_mcp_tools_with_propagated_headers,
test_get_mcp_tools_propagated_headers_do_not_overwrite_auth_headers,
test_get_mcp_tools_propagated_headers_missing_from_request,
test_get_mcp_tools_propagated_headers_no_request_headers,
test_get_mcp_tools_propagated_headers_additive_with_mcp_headers, and
test_get_mcp_tools_mixed_case_precedence to call the helper/fixture instead of
duplicating the mock_config/mocker.patch logic, preserving unique parameters
like headers, authorization_headers, and names per test.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@tests/unit/utils/test_responses.py`:
- Around line 624-795: Several tests repeat the same MCP server/config setup;
extract a small helper or fixture (e.g., a function like make_mcp_server or a
pytest fixture create_mcp_config) to construct ModelContextProtocolServer
instances and patch utils.responses.configuration once; update tests such as
test_get_mcp_tools_with_propagated_headers,
test_get_mcp_tools_propagated_headers_do_not_overwrite_auth_headers,
test_get_mcp_tools_propagated_headers_missing_from_request,
test_get_mcp_tools_propagated_headers_no_request_headers,
test_get_mcp_tools_propagated_headers_additive_with_mcp_headers, and
test_get_mcp_tools_mixed_case_precedence to call the helper/fixture instead of
duplicating the mock_config/mocker.patch logic, preserving unique parameters
like headers, authorization_headers, and names per test.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5d3d1d7 and 256b520.

📒 Files selected for processing (17)
  • README.md
  • docs/config.md
  • examples/lightspeed-stack-mcp-servers.yaml
  • src/app/endpoints/a2a.py
  • src/app/endpoints/query.py
  • src/app/endpoints/rlsapi_v1.py
  • src/app/endpoints/streaming_query.py
  • src/models/config.py
  • src/utils/mcp_headers.py
  • src/utils/responses.py
  • tests/unit/app/endpoints/test_query.py
  • tests/unit/app/endpoints/test_rlsapi_v1.py
  • tests/unit/app/endpoints/test_streaming_query.py
  • tests/unit/models/config/test_dump_configuration.py
  • tests/unit/models/config/test_model_context_protocol_server.py
  • tests/unit/utils/test_mcp_headers.py
  • tests/unit/utils/test_responses.py
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/app/endpoints/streaming_query.py
  • src/models/config.py
  • examples/lightspeed-stack-mcp-servers.yaml
  • src/app/endpoints/rlsapi_v1.py

Copy link
Contributor

@tisnik tisnik left a comment

Choose a reason for hiding this comment

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

LGTM

Copy link
Contributor

@jrobertboos jrobertboos left a comment

Choose a reason for hiding this comment

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

Overall LGTM

no_tools: bool | None,
token: str,
mcp_headers: McpHeaders | None = None,
request_headers: Optional[Mapping[str, str]] = None,
Copy link
Contributor

Choose a reason for hiding this comment

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

Personally I like Optional but at least in this file everything else uses pipes so it might be worth it to stay style consistent.

@tisnik tisnik merged commit 1eba4eb into lightspeed-core:main Feb 25, 2026
22 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants