diff --git a/docker-compose-library.yaml b/docker-compose-library.yaml index 3c198c0a8..65c7e50ac 100644 --- a/docker-compose-library.yaml +++ b/docker-compose-library.yaml @@ -38,6 +38,8 @@ services: - ./run.yaml:/app-root/run.yaml:Z - ${GCP_KEYS_PATH:-./tmp/.gcp-keys-dummy}:/opt/app-root/.gcp-keys:ro - ./tests/e2e/rag:/opt/app-root/src/.llama/storage/rag:Z + - ./tests/e2e/secrets/mcp-token:/tmp/mcp-token:ro + - ./tests/e2e/secrets/invalid-mcp-token:/tmp/invalid-mcp-token:ro environment: # LLM Provider API Keys - BRAVE_SEARCH_API_KEY=${BRAVE_SEARCH_API_KEY:-} diff --git a/docker-compose.yaml b/docker-compose.yaml index 4ee0d30c1..aed2bc0a1 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -81,8 +81,8 @@ services: - "8080:8080" volumes: - ./lightspeed-stack.yaml:/app-root/lightspeed-stack.yaml:z - - ./tests/e2e/secrets/mcp-token:/tmp/mcp-secret-token:ro - - ./tests/e2e/secrets/invalid-mcp-token:/tmp/invalid-mcp-secret-token:ro + - ./tests/e2e/secrets/mcp-token:/tmp/mcp-token:ro + - ./tests/e2e/secrets/invalid-mcp-token:/tmp/invalid-mcp-token:ro environment: - OPENAI_API_KEY=${OPENAI_API_KEY} # Azure Entra ID credentials (AZURE_API_KEY is obtained dynamically) diff --git a/src/app/endpoints/tools.py b/src/app/endpoints/tools.py index c71e64d0f..6f0308872 100644 --- a/src/app/endpoints/tools.py +++ b/src/app/endpoints/tools.py @@ -19,7 +19,7 @@ UnauthorizedResponse, ) from utils.endpoints import check_configuration_loaded -from utils.mcp_headers import McpHeaders, mcp_headers_dependency +from utils.mcp_headers import McpHeaders, build_mcp_headers, mcp_headers_dependency from utils.mcp_oauth_probe import check_mcp_auth from utils.tool_formatter import format_tools_list from log import get_logger @@ -115,15 +115,18 @@ async def tools_endpoint_handler( # pylint: disable=too-many-locals,too-many-st ToolsResponse: An object containing the consolidated list of available tools with metadata including tool name, description, parameters, and server source. """ - # Used only by the middleware - _ = auth + _, _, _, token = auth # Nothing interesting in the request _ = request check_configuration_loaded(configuration) - await check_mcp_auth(configuration, mcp_headers) + complete_mcp_headers = build_mcp_headers( + configuration, mcp_headers, request.headers, token + ) + + await check_mcp_auth(configuration, complete_mcp_headers) toolgroups_response = [] try: @@ -145,7 +148,7 @@ async def tools_endpoint_handler( # pylint: disable=too-many-locals,too-many-st for toolgroup in toolgroups_response: try: # Get tools for each toolgroup - headers = mcp_headers.get(toolgroup.identifier, {}) + headers = complete_mcp_headers.get(toolgroup.identifier, {}) authorization = headers.pop("Authorization", None) tools_response = await client.tools.list( diff --git a/src/utils/mcp_headers.py b/src/utils/mcp_headers.py index 45fbf5e29..1a95ef3f0 100644 --- a/src/utils/mcp_headers.py +++ b/src/utils/mcp_headers.py @@ -2,10 +2,12 @@ import json from collections.abc import Mapping +from typing import Optional from urllib.parse import urlparse from fastapi import Request +import constants from configuration import AppConfig from log import get_logger from models.config import ModelContextProtocolServer @@ -121,3 +123,74 @@ def extract_propagated_headers( if value is not None: propagated[header_name] = value return propagated + + +def build_mcp_headers( + config: AppConfig, + mcp_headers: McpHeaders, + request_headers: Optional[Mapping[str, str]], + token: Optional[str] = None, +) -> McpHeaders: + """Build complete MCP headers by merging all header sources for each MCP server. + + For each configured MCP server, combines four header sources (in priority order, + highest first): + + 1. Client-supplied headers from the ``MCP-HEADERS`` request header (keyed by server name). + 2. Statically resolved authorization headers from configuration (e.g. file-based secrets). + 3. Kubernetes Bearer token: when a header is configured with the ``kubernetes`` keyword, + the supplied ``token`` is formatted as ``Bearer `` and used as its value. + ``client`` and ``oauth`` keywords are not resolved here — those values are already + provided by the client in source 1. + 4. Headers propagated from the incoming request via the server's configured allowlist. + + Args: + config: Application configuration containing mcp_servers. + mcp_headers: Per-request headers from the client, keyed by MCP server name. + request_headers: Headers from the incoming HTTP request used for allowlist + propagation, or ``None`` when not available. + token: Optional Kubernetes service-account token used to resolve headers + configured with the ``kubernetes`` keyword. + + Returns: + McpHeaders keyed by MCP server name with the complete merged set of headers. + Servers that end up with no headers are omitted from the result. + """ + if not config.mcp_servers: + return {} + + complete: McpHeaders = {} + + for mcp_server in config.mcp_servers: + server_headers: dict[str, str] = dict(mcp_headers.get(mcp_server.name, {})) + existing_lower = {k.lower() for k in server_headers} + + for ( + header_name, + resolved_value, + ) in mcp_server.resolved_authorization_headers.items(): + if header_name.lower() in existing_lower: + continue + match resolved_value: + case constants.MCP_AUTH_KUBERNETES: + if token: + server_headers[header_name] = f"Bearer {token}" + existing_lower.add(header_name.lower()) + case constants.MCP_AUTH_CLIENT | constants.MCP_AUTH_OAUTH: + pass # client-provided; already included via the initial mcp_headers copy + case _: + server_headers[header_name] = resolved_value + existing_lower.add(header_name.lower()) + + # Propagate allowlisted headers from the incoming request. + if mcp_server.headers and request_headers is not None: + propagated = extract_propagated_headers(mcp_server, request_headers) + for h_name, h_value in propagated.items(): + if h_name.lower() not in existing_lower: + server_headers[h_name] = h_value + existing_lower.add(h_name.lower()) + + if server_headers: + complete[mcp_server.name] = server_headers + + return complete diff --git a/src/utils/mcp_oauth_probe.py b/src/utils/mcp_oauth_probe.py index a363d07dd..db989a2bb 100644 --- a/src/utils/mcp_oauth_probe.py +++ b/src/utils/mcp_oauth_probe.py @@ -42,7 +42,16 @@ async def check_mcp_auth(configuration: AppConfig, mcp_headers: McpHeaders) -> N probes = [] for mcp_server in configuration.mcp_servers: headers = mcp_headers.get(mcp_server.name, {}) - authorization = headers.get("Authorization", None) + auth_header = headers.get("Authorization") + if auth_header: + authorization = ( + auth_header + if auth_header.startswith("Bearer ") + else f"Bearer {auth_header}" + ) + else: + authorization = None + if ( authorization or constants.MCP_AUTH_OAUTH diff --git a/src/utils/responses.py b/src/utils/responses.py index 48a20e412..0fddcda1c 100644 --- a/src/utils/responses.py +++ b/src/utils/responses.py @@ -50,7 +50,7 @@ NotFoundResponse, ServiceUnavailableResponse, ) -from utils.mcp_headers import McpHeaders, extract_propagated_headers +from utils.mcp_headers import McpHeaders, build_mcp_headers from utils.prompts import get_system_prompt, get_topic_summary_system_prompt from utils.query import ( extract_provider_and_model_from_model_id, @@ -437,17 +437,21 @@ def get_rag_tools(vector_store_ids: list[str]) -> Optional[list[InputToolFileSea ] -async def get_mcp_tools( # pylint: disable=too-many-return-statements,too-many-locals +async def get_mcp_tools( token: Optional[str] = None, mcp_headers: Optional[McpHeaders] = None, request_headers: Optional[Mapping[str, str]] = None, ) -> list[InputToolMCP]: """Convert MCP servers to tools format for Responses API. + Fully delegates header assembly to ``build_mcp_headers``, which handles static + config tokens, the kubernetes Bearer token, client/oauth client-provided headers, + and propagated request headers. + Args: - token: Optional authentication token for MCP server authorization - mcp_headers: Optional per-request headers for MCP servers, keyed by server URL - request_headers: Optional incoming HTTP request headers for allowlist propagation + token: Optional Kubernetes service-account token for ``kubernetes`` auth headers. + mcp_headers: Optional per-request headers for MCP servers, keyed by server name. + request_headers: Optional incoming HTTP request headers for allowlist propagation. Returns: List of MCP tool definitions with server details and optional auth. When @@ -458,68 +462,30 @@ async def get_mcp_tools( # pylint: disable=too-many-return-statements,too-many- HTTPException: 401 with WWW-Authenticate header when an MCP server uses OAuth, no headers are passed, and the server responds with 401 and WWW-Authenticate. """ - - def _get_token_value(original: str, header: str) -> Optional[str]: - """Convert to header value.""" - match original: - case constants.MCP_AUTH_KUBERNETES: - # use k8s token - if token is None or token == "": - return None - return f"Bearer {token}" - case constants.MCP_AUTH_CLIENT: - # use client provided token - if mcp_headers is None: - return None - c_headers = mcp_headers.get(mcp_server.name, None) - if c_headers is None: - return None - return c_headers.get(header, None) - case constants.MCP_AUTH_OAUTH: - # use oauth token - if mcp_headers is None: - return None - c_headers = mcp_headers.get(mcp_server.name, None) - if c_headers is None: - return None - return c_headers.get(header, None) - case _: - # use provided - return original + complete_headers = build_mcp_headers( + configuration, mcp_headers or {}, request_headers, token + ) tools: list[InputToolMCP] = [] for mcp_server in configuration.mcp_servers: - # Build headers - headers: dict[str, str] = {} - for name, value in mcp_server.resolved_authorization_headers.items(): - # for each defined header - h_value = _get_token_value(value, name) - # only add the header if we got value - if h_value is not None: - headers[name] = h_value - - # Skip server if auth headers were configured but not all could be resolved - if mcp_server.authorization_headers and len(headers) != len( - mcp_server.authorization_headers - ): - logger.warning( - "Skipping MCP server %s: required %d auth headers but only resolved %d", - mcp_server.name, - len(mcp_server.authorization_headers), - len(headers), - ) - continue - - # Propagate allowlisted headers from the incoming request - if mcp_server.headers and request_headers is not None: - propagated = extract_propagated_headers(mcp_server, request_headers) - existing_lower = {name.lower() for name in headers} - for h_name, h_value in propagated.items(): - if h_name.lower() not in existing_lower: - headers[h_name] = h_value - existing_lower.add(h_name.lower()) + headers: dict[str, str] = dict(complete_headers.get(mcp_server.name, {})) + + # Skip server if any configured auth header could not be resolved. + if mcp_server.authorization_headers: + unresolved = [ + h + for h in mcp_server.authorization_headers + if not any(k.lower() == h.lower() for k in headers) + ] + if unresolved: + logger.warning( + "Skipping MCP server %s: required %d auth headers but only resolved %d", + mcp_server.name, + len(mcp_server.authorization_headers), + len(mcp_server.authorization_headers) - len(unresolved), + ) + continue - # Build Authorization header authorization = headers.pop("Authorization", None) tools.append( InputToolMCP( diff --git a/tests/e2e/configuration/library-mode/lightspeed-stack-invalid-mcp-file-auth.yaml b/tests/e2e/configuration/library-mode/lightspeed-stack-invalid-mcp-file-auth.yaml index 483e32b73..fd6a66ddd 100644 --- a/tests/e2e/configuration/library-mode/lightspeed-stack-invalid-mcp-file-auth.yaml +++ b/tests/e2e/configuration/library-mode/lightspeed-stack-invalid-mcp-file-auth.yaml @@ -21,4 +21,4 @@ mcp_servers: - name: "mcp-file" url: "http://mock-mcp:3001" authorization_headers: - Authorization: "/tmp/invalid-mcp-secret-token" + Authorization: "/tmp/invalid-mcp-token" diff --git a/tests/e2e/configuration/library-mode/lightspeed-stack-mcp-file-auth.yaml b/tests/e2e/configuration/library-mode/lightspeed-stack-mcp-file-auth.yaml index 79a8807ec..89542e3df 100644 --- a/tests/e2e/configuration/library-mode/lightspeed-stack-mcp-file-auth.yaml +++ b/tests/e2e/configuration/library-mode/lightspeed-stack-mcp-file-auth.yaml @@ -21,4 +21,4 @@ mcp_servers: - name: "mcp-file" url: "http://mock-mcp:3001" authorization_headers: - Authorization: "/tmp/mcp-secret-token" + Authorization: "/tmp/mcp-token" diff --git a/tests/e2e/configuration/library-mode/lightspeed-stack-mcp-kubernetes-auth.yaml b/tests/e2e/configuration/library-mode/lightspeed-stack-mcp-kubernetes-auth.yaml index 2d79f1f9d..1c18aaccc 100644 --- a/tests/e2e/configuration/library-mode/lightspeed-stack-mcp-kubernetes-auth.yaml +++ b/tests/e2e/configuration/library-mode/lightspeed-stack-mcp-kubernetes-auth.yaml @@ -16,7 +16,7 @@ user_data_collection: transcripts_enabled: true transcripts_storage: "/tmp/data/transcripts" authentication: - module: "noop" + module: "noop-with-token" mcp_servers: - name: "mcp-kubernetes" url: "http://mock-mcp:3001" diff --git a/tests/e2e/configuration/library-mode/lightspeed-stack-mcp.yaml b/tests/e2e/configuration/library-mode/lightspeed-stack-mcp.yaml index 647a2cae9..a5c356a63 100644 --- a/tests/e2e/configuration/library-mode/lightspeed-stack-mcp.yaml +++ b/tests/e2e/configuration/library-mode/lightspeed-stack-mcp.yaml @@ -29,7 +29,7 @@ mcp_servers: - name: "mcp-file" url: "http://mock-mcp:3001" authorization_headers: - Authorization: "/tmp/mcp-secret-token" + Authorization: "/tmp/mcp-token" - name: "mcp-client" url: "http://mock-mcp:3001" authorization_headers: diff --git a/tests/e2e/configuration/server-mode/lightspeed-stack-invalid-mcp-file-auth.yaml b/tests/e2e/configuration/server-mode/lightspeed-stack-invalid-mcp-file-auth.yaml index 05ec86fdf..90ad70566 100644 --- a/tests/e2e/configuration/server-mode/lightspeed-stack-invalid-mcp-file-auth.yaml +++ b/tests/e2e/configuration/server-mode/lightspeed-stack-invalid-mcp-file-auth.yaml @@ -22,4 +22,4 @@ mcp_servers: - name: "mcp-file" url: "http://mock-mcp:3001" authorization_headers: - Authorization: "/tmp/invalid-mcp-secret-token" + Authorization: "/tmp/invalid-mcp-token" diff --git a/tests/e2e/configuration/server-mode/lightspeed-stack-mcp-file-auth.yaml b/tests/e2e/configuration/server-mode/lightspeed-stack-mcp-file-auth.yaml index aca5c6ef2..a786c041f 100644 --- a/tests/e2e/configuration/server-mode/lightspeed-stack-mcp-file-auth.yaml +++ b/tests/e2e/configuration/server-mode/lightspeed-stack-mcp-file-auth.yaml @@ -22,4 +22,4 @@ mcp_servers: - name: "mcp-file" url: "http://mock-mcp:3001" authorization_headers: - Authorization: "/tmp/mcp-secret-token" + Authorization: "/tmp/mcp-token" diff --git a/tests/e2e/configuration/server-mode/lightspeed-stack-mcp-kubernetes-auth.yaml b/tests/e2e/configuration/server-mode/lightspeed-stack-mcp-kubernetes-auth.yaml index 66dc7f87b..999010414 100644 --- a/tests/e2e/configuration/server-mode/lightspeed-stack-mcp-kubernetes-auth.yaml +++ b/tests/e2e/configuration/server-mode/lightspeed-stack-mcp-kubernetes-auth.yaml @@ -17,7 +17,7 @@ user_data_collection: transcripts_enabled: true transcripts_storage: "/tmp/data/transcripts" authentication: - module: "noop" + module: "noop-with-token" mcp_servers: - name: "mcp-kubernetes" url: "http://mock-mcp:3001" diff --git a/tests/e2e/configuration/server-mode/lightspeed-stack-mcp.yaml b/tests/e2e/configuration/server-mode/lightspeed-stack-mcp.yaml index e35535f42..8c26194e5 100644 --- a/tests/e2e/configuration/server-mode/lightspeed-stack-mcp.yaml +++ b/tests/e2e/configuration/server-mode/lightspeed-stack-mcp.yaml @@ -30,7 +30,7 @@ mcp_servers: - name: "mcp-file" url: "http://mock-mcp:3001" authorization_headers: - Authorization: "/tmp/mcp-secret-token" + Authorization: "/tmp/mcp-token" - name: "mcp-client" url: "http://mock-mcp:3001" authorization_headers: diff --git a/tests/e2e/features/environment.py b/tests/e2e/features/environment.py index 58a9afe80..1fba35084 100644 --- a/tests/e2e/features/environment.py +++ b/tests/e2e/features/environment.py @@ -435,12 +435,6 @@ def before_feature(context: Context, feature: Feature) -> None: switch_config(context.feature_config) restart_container("lightspeed-stack") - if "MCPFileAuth" in feature.tags: - context.feature_config = _get_config_path("mcp-file-auth", mode_dir) - context.default_config_backup = create_config_backup("lightspeed-stack.yaml") - switch_config(context.feature_config) - restart_container("lightspeed-stack") - def after_feature(context: Context, feature: Feature) -> None: """Run after each feature file is exercised. @@ -473,8 +467,3 @@ def after_feature(context: Context, feature: Feature) -> None: switch_config(context.default_config_backup) restart_container("lightspeed-stack") remove_config_backup(context.default_config_backup) - - if "MCPFileAuth" in feature.tags: - switch_config(context.default_config_backup) - restart_container("lightspeed-stack") - remove_config_backup(context.default_config_backup) diff --git a/tests/e2e/features/mcp.feature b/tests/e2e/features/mcp.feature index 209bced75..6e22dfbf6 100644 --- a/tests/e2e/features/mcp.feature +++ b/tests/e2e/features/mcp.feature @@ -7,11 +7,10 @@ Feature: MCP tests # File-based - @skip #TODO: LCORE-1461 @MCPFileAuthConfig Scenario: Check if tools endpoint succeeds when MCP file-based auth token is passed Given The system is in default state - And The mcp-file mcp server Authorization header is set to "/tmp/mcp-secret-token" + And The mcp-file mcp server Authorization header is set to "/tmp/mcp-token" When I access REST API endpoint "tools" using HTTP GET method Then The status code of the response is 200 And The body of the response contains mcp-file @@ -20,7 +19,7 @@ Feature: MCP tests @MCPFileAuthConfig Scenario: Check if query endpoint succeeds when MCP file-based auth token is passed Given The system is in default state - And The mcp-file mcp server Authorization header is set to "/tmp/mcp-secret-token" + And The mcp-file mcp server Authorization header is set to "/tmp/mcp-token" And I capture the current token metrics When I use "query" to ask question """ @@ -36,7 +35,7 @@ Feature: MCP tests @MCPFileAuthConfig Scenario: Check if streaming_query endpoint succeeds when MCP file-based auth token is passed Given The system is in default state - And The mcp-file mcp server Authorization header is set to "/tmp/mcp-secret-token" + And The mcp-file mcp server Authorization header is set to "/tmp/mcp-token" And I capture the current token metrics When I use "streaming_query" to ask question """ @@ -49,11 +48,10 @@ Feature: MCP tests | Hello | And The token metrics should have increased - @skip #TODO: LCORE-1461 @InvalidMCPFileAuthConfig Scenario: Check if tools endpoint reports error when MCP file-based invalid auth token is passed Given The system is in default state - And The mcp-file mcp server Authorization header is set to "/tmp/invalid-mcp-secret-token" + And The mcp-file mcp server Authorization header is set to "/tmp/invalid-mcp-token" When I access REST API endpoint "tools" using HTTP GET method Then The status code of the response is 401 And The body of the response is the following @@ -70,7 +68,7 @@ Feature: MCP tests @InvalidMCPFileAuthConfig Scenario: Check if query endpoint reports error when MCP file-based invalid auth token is passed Given The system is in default state - And The mcp-file mcp server Authorization header is set to "/tmp/invalid-mcp-secret-token" + And The mcp-file mcp server Authorization header is set to "/tmp/invalid-mcp-token" When I use "query" to ask question """ {"query": "Say hello", "model": "{MODEL}", "provider": "{PROVIDER}"} @@ -90,7 +88,7 @@ Feature: MCP tests @InvalidMCPFileAuthConfig Scenario: Check if streaming_query endpoint reports error when MCP file-based invalid auth token is passed Given The system is in default state - And The mcp-file mcp server Authorization header is set to "/tmp/invalid-mcp-secret-token" + And The mcp-file mcp server Authorization header is set to "/tmp/invalid-mcp-token" When I use "streaming_query" to ask question """ {"query": "Say hello", "model": "{MODEL}", "provider": "{PROVIDER}"} @@ -107,7 +105,6 @@ Feature: MCP tests """ # Kubernetes - @skip #TODO: LCORE-1461 @MCPKubernetesAuthConfig Scenario: Check if tools endpoint succeeds when MCP kubernetes auth token is passed Given The system is in default state @@ -149,7 +146,6 @@ Feature: MCP tests | Hello | And The token metrics should have increased - @skip #TODO: LCORE-1461 @MCPKubernetesAuthConfig Scenario: Check if tools endpoint reports error when MCP kubernetes invalid auth token is passed Given The system is in default state