Skip to content

Commit 20b39cc

Browse files
committed
Update lightspeed-stack configuration and enhance MCP OAuth handling
- Changed the Llama stack URL to localhost for local development. - Added MCP server configuration with authorization headers. - Introduced a new utility function to build request bodies for the Responses API, ensuring proper authorization handling. - Updated response retrieval methods to utilize the new utility function. - Enhanced the MCP OAuth probing function to conditionally include authorization headers and only raise exceptions for 401 responses. - Modified the mock MCP server to handle authorization checks more effectively.
1 parent 4380346 commit 20b39cc

9 files changed

Lines changed: 79 additions & 41 deletions

File tree

lightspeed-stack.yaml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ llama_stack:
1414
# Alternative for "as library use"
1515
# use_as_library_client: true
1616
# library_client_config_path: <path-to-llama-stack-run.yaml-file>
17-
url: http://llama-stack:8321
17+
url: http://localhost:8321
1818
api_key: xyzzy
1919
user_data_collection:
2020
feedback_enabled: true
@@ -35,4 +35,10 @@ authentication:
3535
# OKP Solr for supplementary RAG
3636
solr:
3737
enabled: false
38-
offline: true
38+
offline: true
39+
40+
mcp_servers:
41+
- name: "ggg-mcp-server"
42+
url: "http://localhost:3001"
43+
authorization_headers:
44+
Authorization: "oauth"

src/app/endpoints/query.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
extract_vector_store_ids_from_tools,
5757
get_topic_summary,
5858
prepare_responses_params,
59+
responses_params_to_request_body,
5960
)
6061
from utils.shields import (
6162
append_turn_to_conversation,
@@ -301,7 +302,7 @@ async def retrieve_response( # pylint: disable=too-many-locals
301302
)
302303
return TurnSummary(llm_response=violation_message)
303304
response = await client.responses.create(
304-
**responses_params.model_dump(exclude_none=True)
305+
**responses_params_to_request_body(responses_params),
305306
)
306307
response = cast(OpenAIResponseObject, response)
307308

src/app/endpoints/streaming_query.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
from utils.quota import check_tokens_available, get_available_quotas
7474
from utils.responses import (
7575
build_mcp_tool_call_from_arguments_done,
76+
responses_params_to_request_body,
7677
build_tool_call_summary,
7778
build_tool_result_from_mcp_output_item_done,
7879
deduplicate_referenced_documents,
@@ -303,7 +304,7 @@ async def retrieve_response_generator(
303304
)
304305
# Retrieve response stream (may raise exceptions)
305306
response = await context.client.responses.create(
306-
**responses_params.model_dump(exclude_none=True)
307+
**responses_params_to_request_body(responses_params),
307308
)
308309
# Store pre-RAG documents for later merging
309310
turn_summary.pre_rag_documents = doc_ids_from_chunks

src/app/endpoints/tools.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -156,9 +156,7 @@ async def tools_endpoint_handler( # pylint: disable=too-many-locals,too-many-st
156156
continue
157157
except (AuthenticationError, AuthenticationRequiredError) as e:
158158
if toolgroup.mcp_endpoint:
159-
await probe_mcp_oauth_and_raise_401(
160-
toolgroup.mcp_endpoint.uri, chain_from=e
161-
)
159+
await probe_mcp_oauth_and_raise_401(toolgroup.mcp_endpoint.uri)
162160
error_response = UnauthorizedResponse(cause=str(e))
163161
raise HTTPException(**error_response.model_dump()) from e
164162
except APIConnectionError as e:

src/utils/mcp_oauth_probe.py

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,40 +13,46 @@
1313

1414
async def probe_mcp_oauth_and_raise_401(
1515
url: str,
16-
chain_from: Optional[BaseException] = None,
16+
authorization: Optional[str] = None,
1717
) -> None:
18-
"""Probe MCP endpoint and raise 401 so the client can perform OAuth.
18+
"""Probe MCP endpoint and raise 401 only when the server responds with 401.
1919
20-
Performs an async GET to the given URL to obtain a WWW-Authenticate header,
21-
then raises HTTPException with status 401 and that header. If the probe
22-
fails (connection error, timeout), raises 401 without the header.
20+
Performs a GET to the given URL with the optional Authorization header.
21+
If the response status is 401, raises HTTPException with status 401 and
22+
WWW-Authenticate header when present. Otherwise returns without raising.
2323
2424
Args:
2525
url: MCP server URL to probe.
26+
authorization: Optional Authorization header value (e.g. "Bearer <token>").
2627
chain_from: Exception to chain the HTTPException from when
27-
the probe succeeds (e.g. the original AuthenticationError).
28+
the server returns 401 (e.g. the original AuthenticationError).
2829
2930
Returns:
30-
None. Always raises an HTTPException.
31+
None. Raises only when the server responds with 401.
3132
3233
Raises:
33-
HTTPException: 401 with WWW-Authenticate when the probe succeeds, or
34-
401 without the header when the probe fails.
34+
HTTPException: 401 with WWW-Authenticate when the server returns 401.
3535
"""
3636
cause = f"MCP server at {url} requires OAuth"
3737
error_response = UnauthorizedResponse(cause=cause)
38+
headers: Optional[dict[str, str]] = (
39+
{"Authorization": authorization} if authorization is not None else None
40+
)
3841
try:
3942
timeout = aiohttp.ClientTimeout(total=10)
4043
async with aiohttp.ClientSession(timeout=timeout) as session:
41-
async with session.get(url) as resp:
44+
async with session.get(url, headers=headers) as resp:
45+
print(resp.status)
46+
if resp.status != 401:
47+
return
4248
www_auth = resp.headers.get("WWW-Authenticate")
4349
if www_auth is None:
4450
logger.warning("No WWW-Authenticate header received from %s", url)
45-
raise HTTPException(**error_response.model_dump()) from chain_from
51+
raise HTTPException(**error_response.model_dump())
4652
raise HTTPException(
4753
**error_response.model_dump(),
4854
headers={"WWW-Authenticate": www_auth},
49-
) from chain_from
55+
)
5056
except (aiohttp.ClientError, TimeoutError) as probe_err:
5157
logger.warning("OAuth probe failed for %s: %s", url, probe_err)
52-
raise HTTPException(**error_response.model_dump()) from probe_err
58+
# Only raise on 401; connection/timeout are not 401, so do not raise

src/utils/responses.py

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,33 @@
6565
logger = get_logger(__name__)
6666

6767

68+
def responses_params_to_request_body(params: ResponsesApiParams) -> dict[str, Any]:
69+
"""Build request body for Responses API from ResponsesApiParams.
70+
71+
Serializes params and ensures MCP tool authorization is included (the
72+
llama_stack_api marks it Field(exclude=True), so it is omitted by
73+
model_dump() otherwise).
74+
75+
Parameters:
76+
params: The Responses API parameters.
77+
78+
Returns:
79+
Dict suitable for client.responses.create(**result).
80+
"""
81+
body = params.model_dump(exclude_none=True)
82+
tools = getattr(params, "tools", None)
83+
if tools is not None:
84+
tools_out: list[dict[str, Any]] = []
85+
for tool in tools:
86+
tool_dump = tool.model_dump(exclude_none=True)
87+
auth = getattr(tool, "authorization", None)
88+
if auth is not None:
89+
tool_dump["authorization"] = auth
90+
tools_out.append(tool_dump)
91+
body["tools"] = tools_out
92+
return body
93+
94+
6895
async def get_topic_summary(
6996
question: str, client: AsyncLlamaStackClient, model_id: str
7097
) -> str:
@@ -391,20 +418,20 @@ def _get_token_value(original: str, header: str) -> Optional[str]:
391418
if h_value is not None:
392419
headers[name] = h_value
393420

421+
uses_oauth = (
422+
constants.MCP_AUTH_OAUTH
423+
in mcp_server.resolved_authorization_headers.values()
424+
)
425+
426+
if uses_oauth:
427+
await probe_mcp_oauth_and_raise_401(
428+
mcp_server.url, authorization=headers.get("Authorization", None)
429+
)
430+
394431
# Skip server if auth headers were configured but not all could be resolved
395432
if mcp_server.authorization_headers and len(headers) != len(
396433
mcp_server.authorization_headers
397434
):
398-
# If OAuth was required and no headers passed, probe endpoint and forward
399-
# 401 with WWW-Authenticate so the client can perform OAuth
400-
uses_oauth = (
401-
constants.MCP_AUTH_OAUTH
402-
in mcp_server.resolved_authorization_headers.values()
403-
)
404-
if uses_oauth and (
405-
mcp_headers is None or not mcp_headers.get(mcp_server.name)
406-
):
407-
await probe_mcp_oauth_and_raise_401(mcp_server.url)
408435
logger.warning(
409436
"Skipping MCP server %s: required %d auth headers but only resolved %d",
410437
mcp_server.name,

tests/e2e/features/mcp.feature

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,6 @@ Feature: MCP tests
5656
"""
5757
And The headers of the response contains the following header "www-authenticate"
5858

59-
@skip # will be fixed in LCORE-1368
6059
Scenario: Check if tools endpoint succeeds when MCP auth token is passed
6160
Given The system is in default state
6261
And I set the "MCP-HEADERS" header to
@@ -93,14 +92,13 @@ Feature: MCP tests
9392
"parameters": [],
9493
"provider_id": "",
9594
"toolgroup_id": "mcp-oauth",
96-
"server_source": "http://localhost:3001",
95+
"server_source": "http://mock-mcp:3001",
9796
"type": ""
9897
}
9998
]
10099
}
101100
"""
102101

103-
@skip # will be fixed in LCORE-1366
104102
Scenario: Check if query endpoint succeeds when MCP auth token is passed
105103
Given The system is in default state
106104
And I set the "MCP-HEADERS" header to
@@ -118,7 +116,6 @@ Feature: MCP tests
118116
| hello |
119117
And The token metrics should have increased
120118

121-
@skip # will be fixed in LCORE-1366
122119
Scenario: Check if streaming_query endpoint succeeds when MCP auth token is passed
123120
Given The system is in default state
124121
And I set the "MCP-HEADERS" header to
@@ -137,7 +134,6 @@ Feature: MCP tests
137134
| hello |
138135
And The token metrics should have increased
139136

140-
@skip # will be fixed in LCORE-1368
141137
Scenario: Check if tools endpoint reports error when MCP invalid auth token is passed
142138
Given The system is in default state
143139
And I set the "MCP-HEADERS" header to
@@ -157,7 +153,6 @@ Feature: MCP tests
157153
"""
158154
And The headers of the response contains the following header "www-authenticate"
159155

160-
@skip # will be fixed in LCORE-1366
161156
Scenario: Check if query endpoint reports error when MCP invalid auth token is passed
162157
Given The system is in default state
163158
And I set the "MCP-HEADERS" header to
@@ -180,7 +175,6 @@ Feature: MCP tests
180175
"""
181176
And The headers of the response contains the following header "www-authenticate"
182177

183-
@skip # will be fixed in LCORE-1366
184178
Scenario: Check if streaming_query endpoint reports error when MCP invalid auth token is passed
185179
Given The system is in default state
186180
And I set the "MCP-HEADERS" header to

tests/e2e/mock_mcp_server/server.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,14 @@ def _json_response(self, data: dict) -> None:
4545

4646
def do_GET(self) -> None: # pylint: disable=invalid-name
4747
"""Handle GET requests."""
48-
if self.path == "/health":
49-
self._json_response({"status": "ok"})
50-
else:
51-
self._require_oauth()
48+
if self._parse_auth() is None:
49+
if self.path == "/health":
50+
self._json_response({"status": "ok"})
51+
else:
52+
self._require_oauth()
53+
return
54+
55+
self._json_response({"status": "ok"})
5256

5357
def do_POST(self) -> None: # pylint: disable=invalid-name
5458
"""Handle POST requests."""

tests/unit/utils/test_responses.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -599,6 +599,7 @@ async def test_get_mcp_tools_oauth_no_headers_raises_401_with_www_authenticate(
599599
mocker.patch("utils.responses.configuration", mock_config)
600600

601601
mock_resp = mocker.Mock()
602+
mock_resp.status = 401
602603
mock_resp.headers = {"WWW-Authenticate": 'Bearer error="invalid_token"'}
603604
mock_session = mocker.MagicMock()
604605
mock_get_cm = mocker.AsyncMock()

0 commit comments

Comments
 (0)