Skip to content

Commit 0c394d5

Browse files
committed
(fix) lcore-1461
uncommented tests fixed k8s config fixed black fixed library e2e addressed coderabbit addressed comments fixed "is not None"
1 parent b043837 commit 0c394d5

17 files changed

Lines changed: 357 additions & 95 deletions

docker-compose-library.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ services:
3838
- ./run.yaml:/app-root/run.yaml:Z
3939
- ${GCP_KEYS_PATH:-./tmp/.gcp-keys-dummy}:/opt/app-root/.gcp-keys:ro
4040
- ./tests/e2e/rag:/opt/app-root/src/.llama/storage/rag:Z
41+
- ./tests/e2e/secrets/mcp-token:/tmp/mcp-token:ro
42+
- ./tests/e2e/secrets/invalid-mcp-token:/tmp/invalid-mcp-token:ro
4143
environment:
4244
# LLM Provider API Keys
4345
- BRAVE_SEARCH_API_KEY=${BRAVE_SEARCH_API_KEY:-}

docker-compose.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,8 @@ services:
8181
- "8080:8080"
8282
volumes:
8383
- ./lightspeed-stack.yaml:/app-root/lightspeed-stack.yaml:z
84-
- ./tests/e2e/secrets/mcp-token:/tmp/mcp-secret-token:ro
85-
- ./tests/e2e/secrets/invalid-mcp-token:/tmp/invalid-mcp-secret-token:ro
84+
- ./tests/e2e/secrets/mcp-token:/tmp/mcp-token:ro
85+
- ./tests/e2e/secrets/invalid-mcp-token:/tmp/invalid-mcp-token:ro
8686
environment:
8787
- OPENAI_API_KEY=${OPENAI_API_KEY}
8888
# Azure Entra ID credentials (AZURE_API_KEY is obtained dynamically)

src/app/endpoints/tools.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
UnauthorizedResponse,
2121
)
2222
from utils.endpoints import check_configuration_loaded
23-
from utils.mcp_headers import McpHeaders, mcp_headers_dependency
23+
from utils.mcp_headers import McpHeaders, build_mcp_headers, mcp_headers_dependency
2424
from utils.mcp_oauth_probe import check_mcp_auth
2525
from utils.tool_formatter import format_tools_list
2626

@@ -115,15 +115,18 @@ async def tools_endpoint_handler( # pylint: disable=too-many-locals,too-many-st
115115
ToolsResponse: An object containing the consolidated list of available tools
116116
with metadata including tool name, description, parameters, and server source.
117117
"""
118-
# Used only by the middleware
119-
_ = auth
118+
_, _, _, token = auth
120119

121120
# Nothing interesting in the request
122121
_ = request
123122

124123
check_configuration_loaded(configuration)
125124

126-
await check_mcp_auth(configuration, mcp_headers)
125+
complete_mcp_headers = build_mcp_headers(
126+
configuration, mcp_headers, request.headers, token
127+
)
128+
129+
await check_mcp_auth(configuration, complete_mcp_headers)
127130

128131
toolgroups_response = []
129132
try:
@@ -145,7 +148,7 @@ async def tools_endpoint_handler( # pylint: disable=too-many-locals,too-many-st
145148
for toolgroup in toolgroups_response:
146149
try:
147150
# Get tools for each toolgroup
148-
headers = mcp_headers.get(toolgroup.identifier, {})
151+
headers = complete_mcp_headers.get(toolgroup.identifier, {})
149152
authorization = headers.pop("Authorization", None)
150153

151154
tools_response = await client.tools.list(

src/utils/mcp_headers.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22

33
import json
44
from collections.abc import Mapping
5+
from typing import Optional
56
from urllib.parse import urlparse
67

78
from fastapi import Request
89

10+
import constants
911
from configuration import AppConfig
1012
from log import get_logger
1113
from models.config import ModelContextProtocolServer
@@ -121,3 +123,132 @@ def extract_propagated_headers(
121123
if value is not None:
122124
propagated[header_name] = value
123125
return propagated
126+
127+
128+
def find_unresolved_auth_headers(
129+
configured: Mapping[str, str],
130+
resolved: Mapping[str, str],
131+
) -> list[str]:
132+
"""Return configured auth header names that are absent from the resolved headers.
133+
134+
Comparison is case-insensitive so that ``Authorization`` and ``authorization``
135+
are treated as the same header name.
136+
137+
Args:
138+
configured: The server's ``authorization_headers`` configuration mapping
139+
(header name → secret path or keyword).
140+
resolved: The fully resolved headers that will be sent to the MCP server.
141+
142+
Returns:
143+
List of header names from ``configured`` that could not be resolved, i.e.
144+
are not present as a key in ``resolved``. An empty list means all headers
145+
were resolved successfully.
146+
"""
147+
resolved_lower = {k.lower() for k in resolved}
148+
return [h for h in configured if h.lower() not in resolved_lower]
149+
150+
151+
def build_server_headers(
152+
mcp_server: ModelContextProtocolServer,
153+
client_headers: dict[str, str],
154+
request_headers: Optional[Mapping[str, str]],
155+
token: Optional[str],
156+
) -> dict[str, str]:
157+
"""Build the complete set of headers for a single MCP server.
158+
159+
Merges client-supplied headers, resolved authorization headers, and propagated
160+
request headers in priority order (highest first):
161+
162+
1. Client-supplied headers (already present in ``client_headers``).
163+
2. Statically resolved authorization headers from configuration.
164+
3. Kubernetes Bearer token for headers configured with the ``kubernetes`` keyword.
165+
``client`` and ``oauth`` keywords are skipped — those values are already
166+
provided by the client in source 1.
167+
4. Headers propagated from the incoming request via the server's allowlist.
168+
169+
Args:
170+
mcp_server: MCP server configuration.
171+
client_headers: Headers already supplied by the client for this server.
172+
request_headers: Headers from the incoming HTTP request, or ``None``.
173+
token: Optional Kubernetes service-account token.
174+
175+
Returns:
176+
Merged headers dictionary for the server. May be empty if no headers apply.
177+
"""
178+
server_headers: dict[str, str] = dict(client_headers)
179+
existing_lower = {k.lower() for k in server_headers}
180+
181+
for (
182+
header_name,
183+
resolved_value,
184+
) in mcp_server.resolved_authorization_headers.items():
185+
if header_name.lower() in existing_lower:
186+
continue
187+
match resolved_value:
188+
case constants.MCP_AUTH_KUBERNETES:
189+
if token:
190+
server_headers[header_name] = f"Bearer {token}"
191+
existing_lower.add(header_name.lower())
192+
case constants.MCP_AUTH_CLIENT | constants.MCP_AUTH_OAUTH:
193+
pass # client-provided; already included via the initial client_headers copy
194+
case _:
195+
server_headers[header_name] = resolved_value
196+
existing_lower.add(header_name.lower())
197+
198+
if mcp_server.headers and request_headers is not None:
199+
propagated = extract_propagated_headers(mcp_server, request_headers)
200+
for h_name, h_value in propagated.items():
201+
if h_name.lower() not in existing_lower:
202+
server_headers[h_name] = h_value
203+
existing_lower.add(h_name.lower())
204+
205+
return server_headers
206+
207+
208+
def build_mcp_headers(
209+
config: AppConfig,
210+
mcp_headers: McpHeaders,
211+
request_headers: Optional[Mapping[str, str]],
212+
token: Optional[str] = None,
213+
) -> McpHeaders:
214+
"""Build complete MCP headers by merging all header sources for each MCP server.
215+
216+
For each configured MCP server, combines four header sources (in priority order,
217+
highest first):
218+
219+
1. Client-supplied headers from the ``MCP-HEADERS`` request header (keyed by server name).
220+
2. Statically resolved authorization headers from configuration (e.g. file-based secrets).
221+
3. Kubernetes Bearer token: when a header is configured with the ``kubernetes`` keyword,
222+
the supplied ``token`` is formatted as ``Bearer <token>`` and used as its value.
223+
``client`` and ``oauth`` keywords are not resolved here — those values are already
224+
provided by the client in source 1.
225+
4. Headers propagated from the incoming request via the server's configured allowlist.
226+
227+
Args:
228+
config: Application configuration containing mcp_servers.
229+
mcp_headers: Per-request headers from the client, keyed by MCP server name.
230+
request_headers: Headers from the incoming HTTP request used for allowlist
231+
propagation, or ``None`` when not available.
232+
token: Optional Kubernetes service-account token used to resolve headers
233+
configured with the ``kubernetes`` keyword.
234+
235+
Returns:
236+
McpHeaders keyed by MCP server name with the complete merged set of headers.
237+
Servers that end up with no headers are omitted from the result.
238+
"""
239+
if not config.mcp_servers:
240+
return {}
241+
242+
complete: McpHeaders = {}
243+
244+
for mcp_server in config.mcp_servers:
245+
server_headers = build_server_headers(
246+
mcp_server,
247+
dict(mcp_headers.get(mcp_server.name, {})),
248+
request_headers,
249+
token,
250+
)
251+
if server_headers:
252+
complete[mcp_server.name] = server_headers
253+
254+
return complete

src/utils/mcp_oauth_probe.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,16 @@ async def check_mcp_auth(configuration: AppConfig, mcp_headers: McpHeaders) -> N
4040
probes = []
4141
for mcp_server in configuration.mcp_servers:
4242
headers = mcp_headers.get(mcp_server.name, {})
43-
authorization = headers.get("Authorization", None)
43+
auth_header = headers.get("Authorization")
44+
if auth_header is not None:
45+
authorization = (
46+
auth_header
47+
if auth_header.startswith("Bearer ")
48+
else f"Bearer {auth_header}"
49+
)
50+
else:
51+
authorization = None
52+
4453
if (
4554
authorization
4655
or constants.MCP_AUTH_OAUTH

src/utils/responses.py

Lines changed: 24 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,11 @@
9696
NotFoundResponse,
9797
ServiceUnavailableResponse,
9898
)
99-
from utils.mcp_headers import McpHeaders, extract_propagated_headers
99+
from utils.mcp_headers import (
100+
McpHeaders,
101+
build_mcp_headers,
102+
find_unresolved_auth_headers,
103+
)
100104
from utils.prompts import get_system_prompt, get_topic_summary_system_prompt
101105
from utils.query import (
102106
extract_provider_and_model_from_model_id,
@@ -483,17 +487,21 @@ def get_rag_tools(vector_store_ids: list[str]) -> Optional[list[InputToolFileSea
483487
]
484488

485489

486-
async def get_mcp_tools( # pylint: disable=too-many-return-statements,too-many-locals
490+
async def get_mcp_tools(
487491
token: Optional[str] = None,
488492
mcp_headers: Optional[McpHeaders] = None,
489493
request_headers: Optional[Mapping[str, str]] = None,
490494
) -> list[InputToolMCP]:
491495
"""Convert MCP servers to tools format for Responses API.
492496
497+
Fully delegates header assembly to ``build_mcp_headers``, which handles static
498+
config tokens, the kubernetes Bearer token, client/oauth client-provided headers,
499+
and propagated request headers.
500+
493501
Args:
494-
token: Optional authentication token for MCP server authorization
495-
mcp_headers: Optional per-request headers for MCP servers, keyed by server URL
496-
request_headers: Optional incoming HTTP request headers for allowlist propagation
502+
token: Optional Kubernetes service-account token for ``kubernetes`` auth headers.
503+
mcp_headers: Optional per-request headers for MCP servers, keyed by server name.
504+
request_headers: Optional incoming HTTP request headers for allowlist propagation.
497505
498506
Returns:
499507
List of MCP tool definitions with server details and optional auth. When
@@ -504,68 +512,27 @@ async def get_mcp_tools( # pylint: disable=too-many-return-statements,too-many-
504512
HTTPException: 401 with WWW-Authenticate header when an MCP server uses OAuth,
505513
no headers are passed, and the server responds with 401 and WWW-Authenticate.
506514
"""
507-
508-
def _get_token_value(original: str, header: str) -> Optional[str]:
509-
"""Convert to header value."""
510-
match original:
511-
case constants.MCP_AUTH_KUBERNETES:
512-
# use k8s token
513-
if token is None or token == "":
514-
return None
515-
return f"Bearer {token}"
516-
case constants.MCP_AUTH_CLIENT:
517-
# use client provided token
518-
if mcp_headers is None:
519-
return None
520-
c_headers = mcp_headers.get(mcp_server.name, None)
521-
if c_headers is None:
522-
return None
523-
return c_headers.get(header, None)
524-
case constants.MCP_AUTH_OAUTH:
525-
# use oauth token
526-
if mcp_headers is None:
527-
return None
528-
c_headers = mcp_headers.get(mcp_server.name, None)
529-
if c_headers is None:
530-
return None
531-
return c_headers.get(header, None)
532-
case _:
533-
# use provided
534-
return original
515+
complete_headers = build_mcp_headers(
516+
configuration, mcp_headers or {}, request_headers, token
517+
)
535518

536519
tools: list[InputToolMCP] = []
537520
for mcp_server in configuration.mcp_servers:
538-
# Build headers
539-
headers: dict[str, str] = {}
540-
for name, value in mcp_server.resolved_authorization_headers.items():
541-
# for each defined header
542-
h_value = _get_token_value(value, name)
543-
# only add the header if we got value
544-
if h_value is not None:
545-
headers[name] = h_value
546-
547-
# Skip server if auth headers were configured but not all could be resolved
548-
if mcp_server.authorization_headers and len(headers) != len(
549-
mcp_server.authorization_headers
550-
):
521+
headers: dict[str, str] = dict(complete_headers.get(mcp_server.name, {}))
522+
523+
# Skip server if any configured auth header could not be resolved.
524+
unresolved = find_unresolved_auth_headers(
525+
mcp_server.authorization_headers, headers
526+
)
527+
if unresolved:
551528
logger.warning(
552529
"Skipping MCP server %s: required %d auth headers but only resolved %d",
553530
mcp_server.name,
554531
len(mcp_server.authorization_headers),
555-
len(headers),
532+
len(mcp_server.authorization_headers) - len(unresolved),
556533
)
557534
continue
558535

559-
# Propagate allowlisted headers from the incoming request
560-
if mcp_server.headers and request_headers is not None:
561-
propagated = extract_propagated_headers(mcp_server, request_headers)
562-
existing_lower = {name.lower() for name in headers}
563-
for h_name, h_value in propagated.items():
564-
if h_name.lower() not in existing_lower:
565-
headers[h_name] = h_value
566-
existing_lower.add(h_name.lower())
567-
568-
# Build Authorization header
569536
authorization = headers.pop("Authorization", None)
570537
tools.append(
571538
InputToolMCP(

tests/e2e/configuration/library-mode/lightspeed-stack-invalid-mcp-file-auth.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,4 @@ mcp_servers:
2121
- name: "mcp-file"
2222
url: "http://mock-mcp:3001"
2323
authorization_headers:
24-
Authorization: "/tmp/invalid-mcp-secret-token"
24+
Authorization: "/tmp/invalid-mcp-token"

tests/e2e/configuration/library-mode/lightspeed-stack-mcp-file-auth.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,4 @@ mcp_servers:
2121
- name: "mcp-file"
2222
url: "http://mock-mcp:3001"
2323
authorization_headers:
24-
Authorization: "/tmp/mcp-secret-token"
24+
Authorization: "/tmp/mcp-token"

tests/e2e/configuration/library-mode/lightspeed-stack-mcp-kubernetes-auth.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ user_data_collection:
1616
transcripts_enabled: true
1717
transcripts_storage: "/tmp/data/transcripts"
1818
authentication:
19-
module: "noop"
19+
module: "noop-with-token"
2020
mcp_servers:
2121
- name: "mcp-kubernetes"
2222
url: "http://mock-mcp:3001"

tests/e2e/configuration/library-mode/lightspeed-stack-mcp.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ mcp_servers:
2929
- name: "mcp-file"
3030
url: "http://mock-mcp:3001"
3131
authorization_headers:
32-
Authorization: "/tmp/mcp-secret-token"
32+
Authorization: "/tmp/mcp-token"
3333
- name: "mcp-client"
3434
url: "http://mock-mcp:3001"
3535
authorization_headers:

0 commit comments

Comments
 (0)