Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ services:
- "8080:8080"
volumes:
- ./lightspeed-stack.yaml:/app-root/lightspeed-stack.yaml:z
- ./tests/e2e/secrets/mcp-token:/tmp/mcp-secret-token:ro
environment:
- OPENAI_API_KEY=${OPENAI_API_KEY}
# Azure Entra ID credentials (AZURE_API_KEY is obtained dynamically)
Expand Down
21 changes: 21 additions & 0 deletions src/utils/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,27 @@ class ResponsesApiParams(BaseModel):
description="Extra HTTP headers to send with the request (e.g. x-llamastack-provider-data)",
)

def model_dump(self, *args: Any, **kwargs: Any) -> dict[str, Any]:
"""Serialize params, re-injecting MCP authorization stripped by exclude=True.

llama-stack-api marks ``InputToolMCP.authorization`` with
``Field(exclude=True)`` to prevent token leakage in API responses.
The base ``model_dump()`` therefore strips the field, but we need it
in the request payload so llama-stack server can authenticate with
MCP servers. See LCORE-1414 / GitHub issue #1269.
"""
result = super().model_dump(*args, **kwargs)
dumped_tools = result.get("tools")
if not self.tools or not isinstance(dumped_tools, list):
return result
if len(dumped_tools) != len(self.tools):
return result
for tool, dumped_tool in zip(self.tools, dumped_tools):
authorization = getattr(tool, "authorization", None)
if authorization is not None and isinstance(dumped_tool, dict):
dumped_tool["authorization"] = authorization
return result


class ToolCallSummary(BaseModel):
"""Model representing a tool call made during response generation (for tool_calls list)."""
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: Lightspeed Core Service (LCS)
service:
host: 0.0.0.0
port: 8080
auth_enabled: false
workers: 1
color_log: true
access_log: true
llama_stack:
# Server mode - connects to separate llama-stack service
use_as_library_client: false
url: http://llama-stack:8321
api_key: xyzzy
user_data_collection:
feedback_enabled: true
feedback_storage: "/tmp/data/feedback"
transcripts_enabled: true
transcripts_storage: "/tmp/data/transcripts"
authentication:
module: "noop"
mcp_servers:
- name: "mcp-file-auth"
provider_id: "model-context-protocol"
url: "http://mock-mcp:3001"
authorization_headers:
Authorization: "/tmp/mcp-secret-token"
15 changes: 15 additions & 0 deletions tests/e2e/features/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@
"tests/e2e/configuration/{mode_dir}/lightspeed-stack-auth-rh-identity.yaml",
"tests/e2e-prow/rhoai/configs/lightspeed-stack-auth-rh-identity.yaml",
),
"mcp-file-auth": (
"tests/e2e/configuration/{mode_dir}/lightspeed-stack-mcp-file-auth.yaml",
"tests/e2e-prow/rhoai/configs/lightspeed-stack-mcp-file-auth.yaml",
),
}


Expand Down Expand Up @@ -383,6 +387,12 @@ 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")
Comment on lines +390 to +394
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Avoid stacked config swaps when multiple feature tags are present.

Lines 390–394 and 429–432 use the same independent if pattern as other config tags. If a feature ends up with multiple config-switch tags, hooks can run multiple backup/switch/restore cycles against the same context.default_config_backup, leading to brittle restore behavior. Consider consolidating to a single selected config tag per feature (or enforcing mutual exclusivity) in before_feature/after_feature.

Also applies to: 429-432

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

In `@tests/e2e/features/environment.py` around lines 390 - 394, The test hooks
currently run backup/switch/restore for each config tag (e.g., when
"MCPFileAuth" is present) which can stack and clobber
context.default_config_backup; change before_feature/after_feature to select
exactly one config tag per feature (e.g., detect all config-related tags, pick
one deterministically), set a single
context.feature_config/context.default_config_backup (via create_config_backup)
and call switch_config/restart_container only once for that selected config, and
ensure after_feature restores using that single context.default_config_backup
and clears those context fields; update code paths that reference
create_config_backup, switch_config, restart_container, context.feature_config
and context.default_config_backup so they rely on the single selected config
instead of running per-tag.



def after_feature(context: Context, feature: Feature) -> None:
"""Run after each feature file is exercised.
Expand Down Expand Up @@ -415,3 +425,8 @@ 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)
18 changes: 18 additions & 0 deletions tests/e2e/features/mcp_file_auth.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
@MCPFileAuth
Feature: MCP file-based authorization tests

Regression tests for LCORE-1414: MCP authorization tokens configured via
file-based authorization_headers must survive model_dump() serialization
and reach the MCP server as a valid Bearer token.

Background:
Given The service is started locally
And REST API service prefix is /v1

Scenario: Query succeeds with file-based MCP authorization
Given The system is in default state
When I use "query" to ask question
"""
{"query": "Say hello", "model": "{MODEL}", "provider": "{PROVIDER}"}
"""
Then The status code of the response is 200
Comment thread
coderabbitai[bot] marked this conversation as resolved.
1 change: 1 addition & 0 deletions tests/e2e/secrets/mcp-token
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
test-secret-token
1 change: 1 addition & 0 deletions tests/e2e/test_list.txt
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ features/rlsapi_v1_errors.feature
features/streaming_query.feature
features/rest_api.feature
features/mcp.feature
features/mcp_file_auth.feature
features/models.feature
132 changes: 132 additions & 0 deletions tests/unit/utils/test_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,18 @@

import pytest
from llama_stack_api import ImageContentItem, TextContentItem, URL, _URLOrData
from llama_stack_api.openai_responses import (
OpenAIResponseInputToolFileSearch as InputToolFileSearch,
OpenAIResponseInputToolMCP as InputToolMCP,
)

from pydantic import AnyUrl, ValidationError
from pytest_mock import MockerFixture

from utils.types import (
GraniteToolParser,
ReferencedDocument,
ResponsesApiParams,
ToolCallSummary,
ToolResultSummary,
content_to_str,
Expand Down Expand Up @@ -194,3 +199,130 @@ def test_constructor_partial_fields(self) -> None:
doc = ReferencedDocument(doc_title="Test Title")
assert doc.doc_url is None
assert doc.doc_title == "Test Title"


class TestResponsesApiParamsModelDump:
"""Tests for ResponsesApiParams.model_dump() MCP authorization fix.

Regression tests for LCORE-1414 / GitHub issue #1269: llama-stack-api's
InputToolMCP.authorization has Field(exclude=True), causing the base
model_dump() to silently strip authorization tokens.
"""

def _make_params(self, tools: list) -> ResponsesApiParams:
"""Build minimal ResponsesApiParams with given tools."""
return ResponsesApiParams(
input="test question",
model="provider/model",
conversation="conv-id",
store=False,
stream=False,
tools=tools,
)

def test_mcp_authorization_survives_model_dump(self) -> None:
"""Test that MCP authorization is re-injected after model_dump()."""
tool = InputToolMCP(
server_label="auth-server",
server_url="http://localhost:3000",
require_approval="never",
authorization="my-secret-token",
)
assert tool.authorization == "my-secret-token"
assert "authorization" not in tool.model_dump()

params = self._make_params([tool])
dumped = params.model_dump(exclude_none=True)
assert dumped["tools"][0]["authorization"] == "my-secret-token"

def test_mcp_authorization_none_not_injected(self) -> None:
"""Test that None authorization is not added to the dump."""
tool = InputToolMCP(
server_label="no-auth-server",
server_url="http://localhost:3000",
require_approval="never",
)
params = self._make_params([tool])
dumped = params.model_dump(exclude_none=True)
assert "authorization" not in dumped["tools"][0]

def test_non_mcp_tools_unaffected(self) -> None:
"""Test that non-MCP tools are not modified by the override."""
tool = InputToolFileSearch(
type="file_search",
vector_store_ids=["vs-1"],
)
params = self._make_params([tool])
dumped = params.model_dump(exclude_none=True)
assert "authorization" not in dumped["tools"][0]

def test_mixed_tools_only_mcp_gets_authorization(self) -> None:
"""Test mixed tool list: only MCP tools get authorization re-injected."""
mcp_tool = InputToolMCP(
server_label="auth-server",
server_url="http://localhost:3000",
require_approval="never",
authorization="secret",
)
file_tool = InputToolFileSearch(
type="file_search",
vector_store_ids=["vs-1"],
)
params = self._make_params([file_tool, mcp_tool])
dumped = params.model_dump(exclude_none=True)

assert "authorization" not in dumped["tools"][0]
assert dumped["tools"][1]["authorization"] == "secret"

def test_multiple_mcp_tools_each_preserves_authorization(self) -> None:
"""Test that each MCP tool gets its own authorization re-injected."""
tool_a = InputToolMCP(
server_label="server-a",
server_url="http://a:3000",
require_approval="never",
authorization="token-a",
)
tool_b = InputToolMCP(
server_label="server-b",
server_url="http://b:3000",
require_approval="never",
authorization="token-b",
)
params = self._make_params([tool_a, tool_b])
dumped = params.model_dump(exclude_none=True)

assert dumped["tools"][0]["authorization"] == "token-a"
assert dumped["tools"][1]["authorization"] == "token-b"

def test_exclude_changing_tool_list_shape_skips_reinjection(self) -> None:
"""Test that exclude removing tool indices does not mis-assign authorization."""
tool_a = InputToolMCP(
server_label="server-a",
server_url="http://a:3000",
require_approval="never",
authorization="token-a",
)
tool_b = InputToolMCP(
server_label="server-b",
server_url="http://b:3000",
require_approval="never",
authorization="token-b",
)
params = self._make_params([tool_a, tool_b])
dumped = params.model_dump(exclude={"tools": {0}})
assert len(dumped["tools"]) == 1
assert dumped["tools"][0]["server_label"] == "server-b"
assert "authorization" not in dumped["tools"][0]

def test_no_tools_does_not_error(self) -> None:
"""Test that model_dump() works when tools is None."""
params = ResponsesApiParams(
input="test",
model="provider/model",
conversation="conv-id",
store=False,
stream=False,
tools=None,
)
dumped = params.model_dump(exclude_none=True)
assert "tools" not in dumped
Loading