Skip to content

LCORE-1420: Fixing MCP Authorization#1278

Open
jrobertboos wants to merge 3 commits intolightspeed-core:mainfrom
jrobertboos:lcore-1420
Open

LCORE-1420: Fixing MCP Authorization#1278
jrobertboos wants to merge 3 commits intolightspeed-core:mainfrom
jrobertboos:lcore-1420

Conversation

@jrobertboos
Copy link
Contributor

@jrobertboos jrobertboos commented Mar 5, 2026

Description

Refactored the way that MCP auth is checked. Instead of having it built into utility functions the authentication checking was extracted into a separate function, which is reused across all affected endpoints (/tools, /query, /streaming_query).

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: Cursor
  • Generated by: Cursor

Related Tickets & Documents

  • Related Issue LCORE-1420
  • Closes LCORE-1420

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.

Summary by CodeRabbit

  • Bug Fixes

    • Centralized MCP OAuth verification now runs early for all request types, improving authentication reliability and returning proper 401 challenges when needed.
  • Behavior Changes

    • Authorization header flows standardized; endpoints and test servers now accept Bearer tokens and respond with an authorized result when valid.
  • Tests

    • End-to-end and unit scenarios updated to reflect header-based auth paths and adjusted expectations.

- Removed unused AuthenticationError handling in tools endpoint.
- Updated `probe_mcp_oauth_and_raise_401` to accept an authorization header.
- Enhanced OAuth probing logic to check for 401 status and include authorization in requests.
- Cleaned up e2e tests by removing unnecessary assertions and skips related to MCP authorization.
- Added response handling for authorized status in mock MCP server.
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 5, 2026

Walkthrough

Centralized MCP OAuth checks were introduced: endpoints now call a new async check_mcp_auth after configuration validation. probe_mcp was refactored to accept optional Authorization and to raise HTTPException (with/without WWW-Authenticate). Supporting code and tests were updated to use the new flow.

Changes

Cohort / File(s) Summary
Endpoint Integration
src/app/endpoints/query.py, src/app/endpoints/streaming_query.py, src/app/endpoints/tools.py
Added check_mcp_auth import and an async authentication probe call after configuration validation. Removed legacy probe-based OAuth error handling imports and paths in tools.py.
Core Authentication Utilities
src/utils/mcp_oauth_probe.py
Introduced check_mcp_auth(configuration: AppConfig, mcp_headers: McpHeaders) and refactored probe_mcp(url: str, authorization: Optional[str] = None) -> None. Probing now uses optional Authorization header and raises HTTPException with conditional WWW-Authenticate handling.
Response Handling
src/utils/responses.py
Removed automatic MCP OAuth probing during response/tool construction; incomplete MCP headers now emit warnings and skip probing.
End-to-End Tests & Mock Server
tests/e2e/features/mcp.feature, tests/e2e/mock_mcp_server/server.py
Updated scenarios to use authorization header flows, relaxed tools response assertions, adjusted expected fragments/capitalization, and mock server now returns {"status":"authorized"} for valid Bearer tokens.
Integration Tests
tests/integration/endpoints/test_tools_integration.py
Replaced mocks and expectations to use check_mcp_auth; adjusted fixtures and assertions to reflect HTTPException (401) with/without WWW-Authenticate produced by the new probe behavior.
Unit Tests
tests/unit/app/endpoints/test_tools.py, tests/unit/utils/test_responses.py
Reworked tests to patch check_mcp_auth instead of the old probe; removed reliance on AuthenticationError and deleted a test that assumed probing-on-construction would raise 401 for missing headers.

Sequence Diagram(s)

sequenceDiagram
  participant Client as Client
  participant Endpoint as App Endpoint
  participant Config as AppConfig
  participant MCP as MCP Server
  Client->>Endpoint: HTTP request
  Endpoint->>Config: validate & load configuration
  Endpoint->>Endpoint: await check_mcp_auth(configuration, mcp_headers)
  Endpoint->>MCP: probe_mcp GET (optional Authorization)
  MCP-->>Endpoint: 200 / 401 (+ optional WWW-Authenticate)
  Endpoint-->>Client: proceed or raise HTTPException(401, maybe WWW-Authenticate)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 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 'LCORE-1420: Fixing MCP Authorization' is specific and clearly describes the main change—a refactoring of MCP authentication checking across endpoints.
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 unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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.

- Added `check_mcp_auth` function to probe MCP servers for OAuth requirements.
- Updated `query_endpoint_handler`, `streaming_query_endpoint_handler`, and `tools_endpoint_handler` to call `check_mcp_auth` for OAuth validation.
- Removed deprecated `probe_mcp_oauth_and_raise_401` references from the codebase.
- Enhanced documentation for the new `check_mcp_auth` function to clarify its purpose and usage.
@jrobertboos
Copy link
Contributor Author

In library mode for the /query endpoint I am getting the following error:

ERROR    2026-03-05 13:57:03,128 uvicorn.error:415 uncategorized: Exception in ASGI application                         
                                                                                                                        
         ╭───────────────────────────────────── Traceback (most recent call last) ─────────────────────────────────────╮
         │ /Users/jboos/Code/jrobertboos/lightspeed-stack/.venv/lib/python3.13/site-packages/uvicorn/protocols/http/h1 │
         │ 1_impl.py:410 in run_asgi                                                                                   │
         │                                                                                                             │
         │   407 │   # ASGI exception wrapper                                                                          │
         │   408 │   async def run_asgi(self, app: ASGI3Application) -> None:                                          │
         │   409 │   │   try:                                                                                          │
         │ ❱ 410 │   │   │   result = await app(  # type: ignore[func-returns-value]                                   │
         │   411 │   │   │   │   self.scope, self.receive, self.send                                                   │
         │   412 │   │   │   )                                                                                         │
         │   413 │   │   except BaseException as exc:                                                                  │
         │                                                                                                             │
         │ /Users/jboos/Code/jrobertboos/lightspeed-stack/.venv/lib/python3.13/site-packages/uvicorn/middleware/proxy_ │
         │ headers.py:60 in __call__                                                                                   │
         │                                                                                                             │
         │    57 │   │   │   │   │   port = 0                                                                          │
         │    58 │   │   │   │   │   scope["client"] = (host, port)                                                    │
         │    59 │   │                                                                                                 │
         │ ❱  60 │   │   return await self.app(scope, receive, send)                                                   │
         │    61                                                                                                       │
         │    62                                                                                                       │
         │    63 def _parse_raw_hosts(value: str) -> list[str]:                                                        │
         │                                                                                                             │
         │ /Users/jboos/Code/jrobertboos/lightspeed-stack/.venv/lib/python3.13/site-packages/fastapi/applications.py:1 │
         │ 160 in __call__                                                                                             │
         │                                                                                                             │
         │   1157 │   async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:                    │
         │   1158 │   │   if self.root_path:                                                                           │
         │   1159 │   │   │   scope["root_path"] = self.root_path                                                      │
         │ ❱ 1160 │   │   await super().__call__(scope, receive, send)                                                 │
         │   1161 │                                                                                                    │
         │   1162 │   def add_api_route(                                                                               │
         │   1163 │   │   self,                                                                                        │
         │                                                                                                             │
         │ /Users/jboos/Code/jrobertboos/lightspeed-stack/.venv/lib/python3.13/site-packages/starlette/applications.py │
         │ :107 in __call__                                                                                            │
         │                                                                                                             │
         │   104 │   │   scope["app"] = self                                                                           │
         │   105 │   │   if self.middleware_stack is None:                                                             │
         │   106 │   │   │   self.middleware_stack = self.build_middleware_stack()                                     │
         │ ❱ 107 │   │   await self.middleware_stack(scope, receive, send)                                             │
         │   108 │                                                                                                     │
         │   109 │   def on_event(self, event_type: str) -> Callable:  # type: ignore[type-arg]                        │
         │   110 │   │   return self.router.on_event(event_type)  # pragma: no cover                                   │
         │                                                                                                             │
         │ /Users/jboos/Code/jrobertboos/lightspeed-stack/.venv/lib/python3.13/site-packages/starlette/middleware/erro │
         │ rs.py:164 in __call__                                                                                       │
         │                                                                                                             │
         │   161 │   │   │   await send(message)                                                                       │
         │   162 │   │                                                                                                 │
         │   163 │   │   try:                                                                                          │
         │ ❱ 164 │   │   │   await self.app(scope, receive, _send)                                                     │
         │   165 │   │   except Exception as exc:                                                                      │
         │   166 │   │   │   request = Request(scope)                                                                  │
         │   167 │   │   │   if self.debug:                                                                            │
         │                                                                                                             │
         │ /Users/jboos/Code/jrobertboos/lightspeed-stack/src/app/main.py:168 in __call__                              │
         │                                                                                                             │
         │   165 │   │   # call counter is always incremented, even when the inner app raises.                         │
         │   166 │   │   try:                                                                                          │
         │   167 │   │   │   with metrics.response_duration_seconds.labels(path).time():                               │
         │ ❱ 168 │   │   │   │   await self.app(scope, receive, send_wrapper)                                          │
         │   169 │   │   finally:                                                                                      │
         │   170 │   │   │   # Ignore /metrics endpoint that will be called periodically.                              │
         │   171 │   │   │   if not path.endswith("/metrics"):                                                         │
         │                                                                                                             │
         │ /Users/jboos/Code/jrobertboos/lightspeed-stack/src/app/main.py:203 in __call__                              │
         │                                                                                                             │
         │   200 │   │   │   await send(message)                                                                       │
         │   201 │   │                                                                                                 │
         │   202 │   │   try:                                                                                          │
         │ ❱ 203 │   │   │   await self.app(scope, receive, send_wrapper)                                              │
         │   204 │   │   except HTTPException:                                                                         │
         │   205 │   │   │   raise                                                                                     │
         │   206 │   │   except Exception as exc:  # pylint: disable=broad-exception-caught                            │
         │                                                                                                             │
         │ /Users/jboos/Code/jrobertboos/lightspeed-stack/.venv/lib/python3.13/site-packages/starlette/middleware/cors │
         │ .py:87 in __call__                                                                                          │
         │                                                                                                             │
         │    84 │   │   origin = headers.get("origin")                                                                │
         │    85 │   │                                                                                                 │
         │    86 │   │   if origin is None:                                                                            │
         │ ❱  87 │   │   │   await self.app(scope, receive, send)                                                      │
         │    88 │   │   │   return                                                                                    │
         │    89 │   │                                                                                                 │
         │    90 │   │   if method == "OPTIONS" and "access-control-request-method" in headers:                        │
         │                                                                                                             │
         │ /Users/jboos/Code/jrobertboos/lightspeed-stack/.venv/lib/python3.13/site-packages/starlette/middleware/exce │
         │ ptions.py:63 in __call__                                                                                    │
         │                                                                                                             │
         │   60 │   │   else:                                                                                          │
         │   61 │   │   │   conn = WebSocket(scope, receive, send)                                                     │
         │   62 │   │                                                                                                  │
         │ ❱ 63 │   │   await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)                       │
         │   64 │                                                                                                      │
         │   65 │   async def http_exception(self, request: Request, exc: Exception) -> Response:                      │
         │   66 │   │   assert isinstance(exc, HTTPException)                                                          │
         │                                                                                                             │
         │ /Users/jboos/Code/jrobertboos/lightspeed-stack/.venv/lib/python3.13/site-packages/starlette/_exception_hand │
         │ ler.py:42 in wrapped_app                                                                                    │
         │                                                                                                             │
         │   39 │   │   │   await send(message)                                                                        │
         │   40 │   │                                                                                                  │
         │   41 │   │   try:                                                                                           │
         │ ❱ 42 │   │   │   await app(scope, receive, sender)                                                          │
         │   43 │   │   except Exception as exc:                                                                       │
         │   44 │   │   │   handler = None                                                                             │
         │   45                                                                                                        │
         │                                                                                                             │
         │ /Users/jboos/Code/jrobertboos/lightspeed-stack/.venv/lib/python3.13/site-packages/fastapi/middleware/asynce │
         │ xitstack.py:18 in __call__                                                                                  │
         │                                                                                                             │
         │   15 │   async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:                      │
         │   16 │   │   async with AsyncExitStack() as stack:                                                          │
         │   17 │   │   │   scope[self.context_name] = stack                                                           │
         │ ❱ 18 │   │   │   await self.app(scope, receive, send)                                                       │
         │   19                                                                                                        │
         │                                                                                                             │
         │ /Users/jboos/Code/jrobertboos/lightspeed-stack/.venv/lib/python3.13/site-packages/starlette/routing.py:716  │
         │ in __call__                                                                                                 │
         │                                                                                                             │
         │   713 │   │   """                                                                                           │
         │   714 │   │   The main entry point to the Router class.                                                     │
         │   715 │   │   """                                                                                           │
         │ ❱ 716 │   │   await self.middleware_stack(scope, receive, send)                                             │
         │   717 │                                                                                                     │
         │   718 │   async def app(self, scope: Scope, receive: Receive, send: Send) -> None:                          │
         │   719 │   │   assert scope["type"] in ("http", "websocket", "lifespan")                                     │
         │                                                                                                             │
         │ /Users/jboos/Code/jrobertboos/lightspeed-stack/.venv/lib/python3.13/site-packages/starlette/routing.py:736  │
         │ in app                                                                                                      │
         │                                                                                                             │
         │   733 │   │   │   match, child_scope = route.matches(scope)                                                 │
         │   734 │   │   │   if match == Match.FULL:                                                                   │
         │   735 │   │   │   │   scope.update(child_scope)                                                             │
         │ ❱ 736 │   │   │   │   await route.handle(scope, receive, send)                                              │
         │   737 │   │   │   │   return                                                                                │
         │   738 │   │   │   elif match == Match.PARTIAL and partial is None:                                          │
         │   739 │   │   │   │   partial = route                                                                       │
         │                                                                                                             │
         │ /Users/jboos/Code/jrobertboos/lightspeed-stack/.venv/lib/python3.13/site-packages/starlette/routing.py:290  │
         │ in handle                                                                                                   │
         │                                                                                                             │
         │   287 │   │   │   │   response = PlainTextResponse("Method Not Allowed", status_code=405,                   │
         │       headers=headers)                                                                                      │
         │   288 │   │   │   await response(scope, receive, send)                                                      │
         │   289 │   │   else:                                                                                         │
         │ ❱ 290 │   │   │   await self.app(scope, receive, send)                                                      │
         │   291 │                                                                                                     │
         │   292 │   def __eq__(self, other: Any) -> bool:                                                             │
         │   293 │   │   return (                                                                                      │
         │                                                                                                             │
         │ /Users/jboos/Code/jrobertboos/lightspeed-stack/.venv/lib/python3.13/site-packages/fastapi/routing.py:130 in │
         │ app                                                                                                         │
         │                                                                                                             │
         │    127 │   │   │   │   )                                                                                    │
         │    128 │   │                                                                                                │
         │    129 │   │   # Same as in Starlette                                                                       │
         │ ❱  130 │   │   await wrap_app_handling_exceptions(app, request)(scope, receive, send)                       │
         │    131 │                                                                                                    │
         │    132 │   return app                                                                                       │
         │    133                                                                                                      │
         │                                                                                                             │
         │ /Users/jboos/Code/jrobertboos/lightspeed-stack/.venv/lib/python3.13/site-packages/starlette/_exception_hand │
         │ ler.py:42 in wrapped_app                                                                                    │
         │                                                                                                             │
         │   39 │   │   │   await send(message)                                                                        │
         │   40 │   │                                                                                                  │
         │   41 │   │   try:                                                                                           │
         │ ❱ 42 │   │   │   await app(scope, receive, sender)                                                          │
         │   43 │   │   except Exception as exc:                                                                       │
         │   44 │   │   │   handler = None                                                                             │
         │   45                                                                                                        │
         │                                                                                                             │
         │ /Users/jboos/Code/jrobertboos/lightspeed-stack/.venv/lib/python3.13/site-packages/fastapi/routing.py:116 in │
         │ app                                                                                                         │
         │                                                                                                             │
         │    113 │   │   │   │   scope["fastapi_inner_astack"] = request_stack                                        │
         │    114 │   │   │   │   async with AsyncExitStack() as function_stack:                                       │
         │    115 │   │   │   │   │   scope["fastapi_function_astack"] = function_stack                                │
         │ ❱  116 │   │   │   │   │   response = await f(request)                                                      │
         │    117 │   │   │   │   await response(scope, receive, send)                                                 │
         │    118 │   │   │   │   # Continues customization                                                            │
         │    119 │   │   │   │   response_awaited = True                                                              │
         │                                                                                                             │
         │ /Users/jboos/Code/jrobertboos/lightspeed-stack/.venv/lib/python3.13/site-packages/fastapi/routing.py:670 in │
         │ app                                                                                                         │
         │                                                                                                             │
         │    667 │   │   │   │   response = actual_response_class(content=gen, **response_args)                       │
         │    668 │   │   │   │   response.headers.raw.extend(solved_result.response.headers.raw)                      │
         │    669 │   │   │   else:                                                                                    │
         │ ❱  670 │   │   │   │   raw_response = await run_endpoint_function(                                          │
         │    671 │   │   │   │   │   dependant=dependant,                                                             │
         │    672 │   │   │   │   │   values=solved_result.values,                                                     │
         │    673 │   │   │   │   │   is_coroutine=is_coroutine,                                                       │
         │                                                                                                             │
         │ /Users/jboos/Code/jrobertboos/lightspeed-stack/.venv/lib/python3.13/site-packages/fastapi/routing.py:324 in │
         │ run_endpoint_function                                                                                       │
         │                                                                                                             │
         │    321 │   assert dependant.call is not None, "dependant.call must be a function"                           │
         │    322 │                                                                                                    │
         │    323 │   if is_coroutine:                                                                                 │
         │ ❱  324 │   │   return await dependant.call(**values)                                                        │
         │    325 │   else:                                                                                            │
         │    326 │   │   return await run_in_threadpool(dependant.call, **values)                                     │
         │    327                                                                                                      │
         │                                                                                                             │
         │ /Users/jboos/Code/jrobertboos/lightspeed-stack/src/authorization/middleware.py:187 in wrapper               │
         │                                                                                                             │
         │   184 │   │   @wraps(func)                                                                                  │
         │   185 │   │   async def wrapper(*args: Any, **kwargs: Any) -> Any:                                          │
         │   186 │   │   │   await _perform_authorization_check(action, args, kwargs)                                  │
         │ ❱ 187 │   │   │   return await func(*args, **kwargs)                                                        │
         │   188 │   │                                                                                                 │
         │   189 │   │   return wrapper                                                                                │
         │   190                                                                                                       │
         │                                                                                                             │
         │ /Users/jboos/Code/jrobertboos/lightspeed-stack/src/app/endpoints/query.py:222 in query_endpoint_handler     │
         │                                                                                                             │
         │   219 │   # Get topic summary for new conversation                                                          │
         │   220 │   if not user_conversation and query_request.generate_topic_summary:                                │
         │   221 │   │   logger.debug("Generating topic summary for new conversation")                                 │
         │ ❱ 222 │   │   topic_summary = await get_topic_summary(                                                      │
         │   223 │   │   │   query_request.query, client, responses_params.model                                       │
         │   224 │   │   )                                                                                             │
         │   225 │   else:                                                                                             │
         │                                                                                                             │
         │ /Users/jboos/Code/jrobertboos/lightspeed-stack/src/utils/responses.py:122 in get_topic_summary              │
         │                                                                                                             │
         │    119 │   try:                                                                                             │
         │    120 │   │   response = cast(                                                                             │
         │    121 │   │   │   ResponseObject,                                                                          │
         │ ❱  122 │   │   │   await client.responses.create(                                                           │
         │    123 │   │   │   │   input=question,                                                                      │
         │    124 │   │   │   │   model=model_id,                                                                      │
         │    125 │   │   │   │   instructions=get_topic_summary_system_prompt(),                                      │
         │                                                                                                             │
         │ /Users/jboos/Code/jrobertboos/lightspeed-stack/.venv/lib/python3.13/site-packages/llama_stack_client/resour │
         │ ces/responses/responses.py:732 in create                                                                    │
         │                                                                                                             │
         │   729 │   │   extra_body: Body | None = None,                                                               │
         │   730 │   │   timeout: float | httpx.Timeout | None | NotGiven = not_given,                                 │
         │   731 │   ) -> ResponseObject | AsyncStream[ResponseObjectStream]:                                          │
         │ ❱ 732 │   │   return await self._post(                                                                      │
         │   733 │   │   │   "/v1/responses",                                                                          │
         │   734 │   │   │   body=await async_maybe_transform(                                                         │
         │   735 │   │   │   │   {                                                                                     │
         │                                                                                                             │
         │ /Users/jboos/Code/jrobertboos/lightspeed-stack/.venv/lib/python3.13/site-packages/llama_stack_client/_base_ │
         │ client.py:1775 in post                                                                                      │
         │                                                                                                             │
         │   1772 │   │   opts = FinalRequestOptions.construct(                                                        │
         │   1773 │   │   │   method="post", url=path, json_data=body, files=await                                     │
         │        async_to_httpx_files(files), **options                                                               │
         │   1774 │   │   )                                                                                            │
         │ ❱ 1775 │   │   return await self.request(cast_to, opts, stream=stream, stream_cls=stream_cls)               │
         │   1776 │                                                                                                    │
         │   1777 │   async def patch(                                                                                 │
         │   1778 │   │   self,                                                                                        │
         │                                                                                                             │
         │ /Users/jboos/Code/jrobertboos/lightspeed-stack/.venv/lib/python3.13/site-packages/llama_stack/core/library_ │
         │ client.py:390 in request                                                                                    │
         │                                                                                                             │
         │   387 │   │   │   │   │   stream_cls=stream_cls,                                                            │
         │   388 │   │   │   │   )                                                                                     │
         │   389 │   │   │   else:                                                                                     │
         │ ❱ 390 │   │   │   │   response = await self._call_non_streaming(                                            │
         │   391 │   │   │   │   │   cast_to=cast_to,                                                                  │
         │   392 │   │   │   │   │   options=options,                                                                  │
         │   393 │   │   │   │   )                                                                                     │
         │                                                                                                             │
         │ /Users/jboos/Code/jrobertboos/lightspeed-stack/.venv/lib/python3.13/site-packages/llama_stack/core/library_ │
         │ client.py:448 in _call_non_streaming                                                                        │
         │                                                                                                             │
         │   445 │   │   body, field_names = self._handle_file_uploads(options, body)                                  │
         │   446 │   │                                                                                                 │
         │   447 │   │   body = self._convert_body(matched_func, body, exclude_params=set(field_names))                │
         │ ❱ 448 │   │   result = await matched_func(**body)                                                           │
         │   449 │   │                                                                                                 │
         │   450 │   │   # Handle FastAPI Response objects (e.g., from file content retrieval)                         │
         │   451 │   │   if isinstance(result, FastAPIResponse):                                                       │
         │                                                                                                             │
         │ /Users/jboos/Code/jrobertboos/lightspeed-stack/.venv/lib/python3.13/site-packages/llama_stack/providers/inl │
         │ ine/agents/meta_reference/agents.py:118 in create_openai_response                                           │
         │                                                                                                             │
         │   115 │   │   metadata: dict[str, str] | None = None,                                                       │
         │   116 │   ) -> OpenAIResponseObject:                                                                        │
         │   117 │   │   assert self.openai_responses_impl is not None, "OpenAI responses not                          │
         │       initialized"                                                                                          │
         │ ❱ 118 │   │   result = await self.openai_responses_impl.create_openai_response(                             │
         │   119 │   │   │   input,                                                                                    │
         │   120 │   │   │   model,                                                                                    │
         │   121 │   │   │   prompt,                                                                                   │
         │                                                                                                             │
         │ /Users/jboos/Code/jrobertboos/lightspeed-stack/.venv/lib/python3.13/site-packages/llama_stack/providers/inl │
         │ ine/agents/meta_reference/responses/openai_responses.py:413 in create_openai_response                       │
         │                                                                                                             │
         │   410 │   │   │   final_event_type = None                                                                   │
         │   411 │   │   │   failed_response = None                                                                    │
         │   412 │   │   │                                                                                             │
         │ ❱ 413 │   │   │   async for stream_chunk in stream_gen:                                                     │
         │   414 │   │   │   │   match stream_chunk.type:                                                              │
         │   415 │   │   │   │   │   case "response.completed" | "response.incomplete":                                │
         │   416 │   │   │   │   │   │   if final_response is not None:                                                │
         │                                                                                                             │
         │ /Users/jboos/Code/jrobertboos/lightspeed-stack/.venv/lib/python3.13/site-packages/llama_stack/providers/inl │
         │ ine/agents/meta_reference/responses/openai_responses.py:532 in _create_streaming_response                   │
         │                                                                                                             │
         │   529 │   │   # Type as ConversationItem to avoid list invariance issues                                    │
         │   530 │   │   output_items: list[ConversationItem] = []                                                     │
         │   531 │   │   try:                                                                                          │
         │ ❱ 532 │   │   │   async for stream_chunk in orchestrator.create_response():                                 │
         │   533 │   │   │   │   match stream_chunk.type:                                                              │
         │   534 │   │   │   │   │   case "response.completed" | "response.incomplete":                                │
         │   535 │   │   │   │   │   │   final_response = stream_chunk.response                                        │
         │                                                                                                             │
         │ /Users/jboos/Code/jrobertboos/lightspeed-stack/.venv/lib/python3.13/site-packages/llama_stack/providers/inl │
         │ ine/agents/meta_reference/responses/streaming.py:328 in create_response                                     │
         │                                                                                                             │
         │    325 │   │   │   │   │   },                                                                               │
         │    326 │   │   │   │   │   logprobs=logprobs,                                                               │
         │    327 │   │   │   │   )                                                                                    │
         │ ❱  328 │   │   │   │   completion_result = await                                                            │
         │        self.inference_api.openai_chat_completion(params)                                                    │
         │    329 │   │   │   │                                                                                        │
         │    330 │   │   │   │   # Process streaming chunks and build complete response                               │
         │    331 │   │   │   │   completion_result_data = None                                                        │
         │                                                                                                             │
         │ /Users/jboos/Code/jrobertboos/lightspeed-stack/.venv/lib/python3.13/site-packages/llama_stack/core/routers/ │
         │ inference.py:195 in openai_chat_completion                                                                  │
         │                                                                                                             │
         │   192 │   │   │   params.tools = None                                                                       │
         │   193 │   │                                                                                                 │
         │   194 │   │   if params.stream:                                                                             │
         │ ❱ 195 │   │   │   response_stream = await provider.openai_chat_completion(params)                           │
         │   196 │   │   │                                                                                             │
         │   197 │   │   │   # For streaming, the provider returns                                                     │
         │       AsyncIterator[OpenAIChatCompletionChunk]                                                              │
         │   198 │   │   │   # We need to add metrics to each chunk and store the final completion                     │
         │                                                                                                             │
         │ /Users/jboos/Code/jrobertboos/lightspeed-stack/.venv/lib/python3.13/site-packages/llama_stack/providers/uti │
         │ ls/inference/openai_mixin.py:378 in openai_chat_completion                                                  │
         │                                                                                                             │
         │   375 │   │                                                                                                 │
         │   376 │   │   if extra_body := params.model_extra:                                                          │
         │   377 │   │   │   request_params["extra_body"] = extra_body                                                 │
         │ ❱ 378 │   │   resp = await self.client.chat.completions.create(**request_params)                            │
         │   379 │   │                                                                                                 │
         │   380 │   │   return await self._maybe_overwrite_id(resp, params.stream)  # type:                           │
         │       ignore[no-any-return]                                                                                 │
         │   381                                                                                                       │
         │                                                                                                             │
         │ /Users/jboos/Code/jrobertboos/lightspeed-stack/.venv/lib/python3.13/site-packages/openai/resources/chat/com │
         │ pletions/completions.py:2700 in create                                                                      │
         │                                                                                                             │
         │   2697 │   │   timeout: float | httpx.Timeout | None | NotGiven = not_given,                                │
         │   2698 │   ) -> ChatCompletion | AsyncStream[ChatCompletionChunk]:                                          │
         │   2699 │   │   validate_response_format(response_format)                                                    │
         │ ❱ 2700 │   │   return await self._post(                                                                     │
         │   2701 │   │   │   "/chat/completions",                                                                     │
         │   2702 │   │   │   body=await async_maybe_transform(                                                        │
         │   2703 │   │   │   │   {                                                                                    │
         │                                                                                                             │
         │ /Users/jboos/Code/jrobertboos/lightspeed-stack/.venv/lib/python3.13/site-packages/openai/_base_client.py:18 │
         │ 84 in post                                                                                                  │
         │                                                                                                             │
         │   1881 │   │   opts = FinalRequestOptions.construct(                                                        │
         │   1882 │   │   │   method="post", url=path, json_data=body, content=content, files=await                    │
         │        async_to_httpx_files(files), **options                                                               │
         │   1883 │   │   )                                                                                            │
         │ ❱ 1884 │   │   return await self.request(cast_to, opts, stream=stream, stream_cls=stream_cls)               │
         │   1885 │                                                                                                    │
         │   1886 │   async def patch(                                                                                 │
         │   1887 │   │   self,                                                                                        │
         │                                                                                                             │
         │ /Users/jboos/Code/jrobertboos/lightspeed-stack/.venv/lib/python3.13/site-packages/openai/_base_client.py:15 │
         │ 69 in request                                                                                               │
         │                                                                                                             │
         │   1566 │   │   if self._platform is None:                                                                   │
         │   1567 │   │   │   # `get_platform` can make blocking IO calls so we                                        │
         │   1568 │   │   │   # execute it earlier while we are in an async context                                    │
         │ ❱ 1569 │   │   │   self._platform = await asyncify(get_platform)()                                          │
         │   1570 │   │                                                                                                │
         │   1571 │   │   cast_to = self._maybe_override_cast_to(cast_to, options)                                     │
         │   1572                                                                                                      │
         │                                                                                                             │
         │ /Users/jboos/Code/jrobertboos/lightspeed-stack/.venv/lib/python3.13/site-packages/openai/_utils/_sync.py:56 │
         │ in wrapper                                                                                                  │
         │                                                                                                             │
         │   53 │   """                                                                                                │
         │   54 │                                                                                                      │
         │   55 │   async def wrapper(*args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs) ->                        │
         │      T_Retval:                                                                                              │
         │ ❱ 56 │   │   return await to_thread(function, *args, **kwargs)                                              │
         │   57 │                                                                                                      │
         │   58 │   return wrapper                                                                                     │
         │   59                                                                                                        │
         │                                                                                                             │
         │ /Users/jboos/Code/jrobertboos/lightspeed-stack/.venv/lib/python3.13/site-packages/openai/_utils/_sync.py:20 │
         │ in to_thread                                                                                                │
         │                                                                                                             │
         │   17 │   func: Callable[T_ParamSpec, T_Retval], /, *args: T_ParamSpec.args, **kwargs:                       │
         │      T_ParamSpec.kwargs                                                                                     │
         │   18 ) -> T_Retval:                                                                                         │
         │   19 │   if sniffio.current_async_library() == "asyncio":                                                   │
         │ ❱ 20 │   │   return await asyncio.to_thread(func, *args, **kwargs)                                          │
         │   21 │                                                                                                      │
         │   22 │   return await anyio.to_thread.run_sync(                                                             │
         │   23 │   │   functools.partial(func, *args, **kwargs),                                                      │
         │                                                                                                             │
         │ /opt/homebrew/Cellar/python@3.13/3.13.12/Frameworks/Python.framework/Versions/3.13/lib/python3.13/asyncio/t │
         │ hreads.py:25 in to_thread                                                                                   │
         │                                                                                                             │
         │   22 │   loop = events.get_running_loop()                                                                   │
         │   23 │   ctx = contextvars.copy_context()                                                                   │
         │   24 │   func_call = functools.partial(ctx.run, func, *args, **kwargs)                                      │
         │ ❱ 25 │   return await loop.run_in_executor(None, func_call)                                                 │
         │   26                                                                                                        │
         ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
         CancelledError: Cancelled via cancel scope 11cd32e10 by <Task pending name='Task-168'                          
         coro=<MCPSessionManager.close_all() running at                                                                 
         /Users/jboos/Code/jrobertboos/lightspeed-stack/.venv/lib/python3.13/site-packages/llama_stack/providers/utils/t
         ools/mcp.py:259> cb=[shield.<locals>._inner_done_callback() at                                                 
         /opt/homebrew/Cellar/python@3.13/3.13.12/Frameworks/Python.framework/Versions/3.13/lib/python3.13/asyncio/tasks
         .py:958]>                                                                                                      

This is likely what is also causing the /streaming_query endpoint to fail.

@jrobertboos jrobertboos marked this pull request as ready for review March 5, 2026 19:43
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: 3

🤖 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/app/endpoints/query.py`:
- Around line 126-127: The non-streaming query endpoint must catch
asyncio.CancelledError around the blocking calls to get_topic_summary(...) and
the call chain that invokes responses.create(...) inside retrieve_response(...)
to avoid 500s on client cancellation; update the handler in query.py to wrap
those await calls (or the higher-level retrieve_response call) with an except
asyncio.CancelledError: block that logs or silently returns an appropriate
response/cleanup and re-raises or returns gracefully (mirroring the streaming
handler pattern), ensuring you reference get_topic_summary, retrieve_response,
and responses.create to locate the spots to add the cancellation handling.

In `@src/app/endpoints/tools.py`:
- Around line 148-150: The code mutates the request-scoped mcp_headers by
calling headers = mcp_headers.get(toolgroup.identifier, {}) followed by
headers.pop("Authorization", None); change this to operate on a local copy:
retrieve the dict via mcp_headers.get(toolgroup.identifier) (or {}), create a
shallow copy (e.g., dict(headers) or headers.copy()) into a new variable (e.g.,
local_headers), then extract the Authorization value from that copy into
authorization without modifying the original mcp_headers; update subsequent uses
to reference local_headers instead of headers (look for occurrences around
toolgroup.identifier, headers, and authorization in src/app/endpoints/tools.py).

In `@src/utils/mcp_oauth_probe.py`:
- Around line 42-43: The code currently retrieves the Authorization token using
headers.get("Authorization") which is case-sensitive; update the lookup in
mcp_oauth_probe.py to be case-insensitive by normalizing header keys (e.g.,
build a temporary dict with lowercased keys from headers and then use lowercased
"authorization") or check both "Authorization" and "authorization" before
falling back to None so the token is found regardless of header casing for
mcp_headers, headers, authorization, and mcp_server.name.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 82a1905f-4294-4403-b48a-0d0edf768690

📥 Commits

Reviewing files that changed from the base of the PR and between b0fe07d and e163cd7.

📒 Files selected for processing (10)
  • src/app/endpoints/query.py
  • src/app/endpoints/streaming_query.py
  • src/app/endpoints/tools.py
  • src/utils/mcp_oauth_probe.py
  • src/utils/responses.py
  • tests/e2e/features/mcp.feature
  • tests/e2e/mock_mcp_server/server.py
  • tests/integration/endpoints/test_tools_integration.py
  • tests/unit/app/endpoints/test_tools.py
  • tests/unit/utils/test_responses.py
💤 Files with no reviewable changes (2)
  • src/utils/responses.py
  • tests/unit/utils/test_responses.py

Comment on lines +126 to +127
await check_mcp_auth(configuration, mcp_headers)

Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect cancellation handling vs. response/topic-summary call sites.
rg -n "responses\.create|get_topic_summary|CancelledError" src/app/endpoints/query.py src/app/endpoints/streaming_query.py

Repository: lightspeed-core/lightspeed-stack

Length of output: 932


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read query.py around the critical lines
echo "=== query.py around line 126-127 (auth check) ==="
sed -n '120,135p' src/app/endpoints/query.py

echo ""
echo "=== query.py around line 222 (get_topic_summary call) ==="
sed -n '200,240p' src/app/endpoints/query.py

echo ""
echo "=== query.py around line 318 (responses.create call) ==="
sed -n '300,330p' src/app/endpoints/query.py

Repository: lightspeed-core/lightspeed-stack

Length of output: 3859


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Get the function definition and overall structure
echo "=== query.py function signature and structure (lines 1-150) ==="
sed -n '80,150p' src/app/endpoints/query.py

echo ""
echo "=== Check for outer try-except blocks in query handler ==="
rg -n "try:|except|CancelledError" src/app/endpoints/query.py | head -20

Repository: lightspeed-core/lightspeed-stack

Length of output: 3403


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# See lines 200-350 to understand exception handling scope
echo "=== query.py lines 200-350 (full context) ==="
sed -n '200,350p' src/app/endpoints/query.py | cat -n

echo ""
echo "=== streaming_query.py exception handling around line 490 ==="
sed -n '480,520p' src/app/endpoints/streaming_query.py | cat -n

Repository: lightspeed-core/lightspeed-stack

Length of output: 8827


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check get_topic_summary implementation
echo "=== get_topic_summary implementation ==="
rg -A 15 "^async def get_topic_summary" src/

Repository: lightspeed-core/lightspeed-stack

Length of output: 1031


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Get the full get_topic_summary implementation
echo "=== get_topic_summary full implementation ==="
rg -A 40 "^async def get_topic_summary" src/utils/responses.py

Repository: lightspeed-core/lightspeed-stack

Length of output: 1623


Add CancelledError handling to non-streaming query path to prevent 500 errors during request cancellation.

The non-streaming query endpoint lacks explicit cancellation handling for get_topic_summary() (line 222) and responses.create() (within retrieve_response()). If the client cancels the request during either call, the unhandled CancelledError propagates to the ASGI layer, resulting in a 500 error. The streaming endpoint handles this correctly with an explicit except asyncio.CancelledError: handler (see streaming_query.py:490), but the non-streaming path needs equivalent protection to prevent 500 responses when authenticated MCP requests are interrupted.

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

In `@src/app/endpoints/query.py` around lines 126 - 127, The non-streaming query
endpoint must catch asyncio.CancelledError around the blocking calls to
get_topic_summary(...) and the call chain that invokes responses.create(...)
inside retrieve_response(...) to avoid 500s on client cancellation; update the
handler in query.py to wrap those await calls (or the higher-level
retrieve_response call) with an except asyncio.CancelledError: block that logs
or silently returns an appropriate response/cleanup and re-raises or returns
gracefully (mirroring the streaming handler pattern), ensuring you reference
get_topic_summary, retrieve_response, and responses.create to locate the spots
to add the cancellation handling.

Comment on lines 148 to +150
headers = mcp_headers.get(toolgroup.identifier, {})
authorization = headers.pop("Authorization", None)

Copy link
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 mutating request-scoped MCP headers in-place.

Line 149 uses pop, which mutates the dict sourced from mcp_headers. Use a local copy/extraction so downstream code sees stable input.

♻️ Proposed fix
-            headers = mcp_headers.get(toolgroup.identifier, {})
-            authorization = headers.pop("Authorization", None)
+            original_headers = mcp_headers.get(toolgroup.identifier, {})
+            authorization = next(
+                (v for k, v in original_headers.items() if k.lower() == "authorization"),
+                None,
+            )
+            headers = {
+                k: v for k, v in original_headers.items() if k.lower() != "authorization"
+            }

As per coding guidelines "Avoid in-place parameter modification anti-patterns; return new data structures instead."

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

In `@src/app/endpoints/tools.py` around lines 148 - 150, The code mutates the
request-scoped mcp_headers by calling headers =
mcp_headers.get(toolgroup.identifier, {}) followed by
headers.pop("Authorization", None); change this to operate on a local copy:
retrieve the dict via mcp_headers.get(toolgroup.identifier) (or {}), create a
shallow copy (e.g., dict(headers) or headers.copy()) into a new variable (e.g.,
local_headers), then extract the Authorization value from that copy into
authorization without modifying the original mcp_headers; update subsequent uses
to reference local_headers instead of headers (look for occurrences around
toolgroup.identifier, headers, and authorization in src/app/endpoints/tools.py).

Comment on lines +42 to +43
headers = mcp_headers.get(mcp_server.name, {})
authorization = headers.get("Authorization", None)
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Handle Authorization lookup case-insensitively.

Line 43 only checks "Authorization" with exact casing. If MCP-HEADERS sends "authorization", the token is ignored and OAuth probing can incorrectly return 401.

♻️ Proposed fix
-        authorization = headers.get("Authorization", None)
+        authorization = next(
+            (value for key, value in headers.items() if key.lower() == "authorization"),
+            None,
+        )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/mcp_oauth_probe.py` around lines 42 - 43, The code currently
retrieves the Authorization token using headers.get("Authorization") which is
case-sensitive; update the lookup in mcp_oauth_probe.py to be case-insensitive
by normalizing header keys (e.g., build a temporary dict with lowercased keys
from headers and then use lowercased "authorization") or check both
"Authorization" and "authorization" before falling back to None so the token is
found regardless of header casing for mcp_headers, headers, authorization, and
mcp_server.name.

@jrobertboos
Copy link
Contributor Author

If this PR wants to be merged now I can add @skip-in-library-mode tags to the failing tests

@tisnik tisnik requested a review from max-svistunov March 6, 2026 14:32
@tisnik
Copy link
Contributor

tisnik commented Mar 6, 2026

@max-svistunov please take a look. TYVM

Copy link
Contributor

@max-svistunov max-svistunov left a comment

Choose a reason for hiding this comment

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

MCP auth checks moved to early in the pipeline is much cleaner 👌. I left just two comments to consider.

except BadRequestError:
logger.error("Toolgroup %s is not found", toolgroup.identifier)
continue
except (AuthenticationError, AuthenticationRequiredError) as e:
Copy link
Contributor

Choose a reason for hiding this comment

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

Here we handled auth from llamastack itself as well, not just from MCP Oath. Now the check_mcp_auth only covers the MCP Oauth scenario. So if client.tools.list() now gives AuthenticationError for any problem that is not MCP-related, it now puts forward a 500 error instead of 401.

A simple fix could be: re-add catch for AuthenticationError (to return 401 again) -- the deleted lines 157, 162, and 163:

except AuthenticationError as e:
error_response = UnauthorizedResponse(cause=str(e))
raise HTTPException(**error_response.model_dump()) from e

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The only reason I got rid of the llama stack authentication error catching in /tools is because it results in an asymmetrical implementation between tools and the query endpoints. I can not think of a scenario where client.tools.list() would return a 401 that has not already been caught (because only mcp servers are able to be authd against in llamastack)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Actually I might be able to add an exception handler in the handle_known_apistatus_errors() and extend that to be used in /tools wdyt?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Actually I might be able to add an exception handler in the handle_known_apistatus_errors() and extend that to be used in /tools wdyt?

But should I add this if its basically an impossible to reach branch?

Copy link
Contributor

Choose a reason for hiding this comment

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

Agreed that currently only MCP servers trigger auth in llamastack. OTOH, it might be worth it as defense-in-depth -- if llamastack adds auth for non-MCP reason we would get 500 errors again. It's a low risk though, so up to you 🤷

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I ended up not implementing this error catching mainly to reduce size of this PR as well as not add any (as of right now) unneeded complexity. If this becomes an issue we will address it at that time :). However with the toolgroup depreciation large changes will likely begin to emerge that will likely make any work done with this exception catching irrelevant so for the time being I don't think its necessary.

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/e2e/features/mcp.feature (1)

67-67: Looser assertion is acceptable but less precise.

Changing from an exact JSON body assertion to "contains mcp-oauth" provides flexibility but reduces specificity. This may mask regressions in the response structure.

Consider whether a more targeted assertion (e.g., checking for specific fields while ignoring dynamic values) would better balance flexibility and coverage.

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

In `@tests/e2e/features/mcp.feature` at line 67, The step "And The body of the
response contains mcp-oauth" is too loose; replace it with a targeted assertion
that verifies the response JSON contains the expected keys/fields (e.g., the
mcp-oauth object and its required properties) or validate against a small JSON
schema instead of a substring match; update the step definition that implements
"The body of the response contains mcp-oauth" to parse the response body as JSON
and assert presence and types of specific fields (e.g., client_id, access_token,
expires_in) so tests remain flexible for dynamic values but still catch
structural regressions.
🤖 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/e2e/features/mcp.feature`:
- Line 67: The step "And The body of the response contains mcp-oauth" is too
loose; replace it with a targeted assertion that verifies the response JSON
contains the expected keys/fields (e.g., the mcp-oauth object and its required
properties) or validate against a small JSON schema instead of a substring
match; update the step definition that implements "The body of the response
contains mcp-oauth" to parse the response body as JSON and assert presence and
types of specific fields (e.g., client_id, access_token, expires_in) so tests
remain flexible for dynamic values but still catch structural regressions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8a194b1f-023c-4eb7-8bcd-1ecfe9f35d66

📥 Commits

Reviewing files that changed from the base of the PR and between e163cd7 and ae48ed0.

📒 Files selected for processing (2)
  • src/utils/mcp_oauth_probe.py
  • tests/e2e/features/mcp.feature

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