Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ The service includes comprehensive user data collection capabilities for various
* [1. Static Tokens from Files (Recommended for Service Credentials)](#1-static-tokens-from-files-recommended-for-service-credentials)
* [2. Kubernetes Service Account Tokens (For K8s Deployments)](#2-kubernetes-service-account-tokens-for-k8s-deployments)
* [3. Client-Provided Tokens (For Per-User Authentication)](#3-client-provided-tokens-for-per-user-authentication)
* [4. OAuth (For MCP Servers Requiring OAuth)](#4-oauth-for-mcp-servers-requiring-oauth)
* [Client-Authenticated MCP Servers Discovery](#client-authenticated-mcp-servers-discovery)
* [Combining Authentication Methods](#combining-authentication-methods)
* [Authentication Method Comparison](#authentication-method-comparison)
Expand Down Expand Up @@ -355,7 +356,7 @@ In addition to the basic configuration above, you can configure authentication h

#### Configuring MCP Server Authentication

Lightspeed Core Stack supports three methods for authenticating with MCP servers, each suited for different use cases:
Lightspeed Core Stack supports four methods for authenticating with MCP servers, each suited for different use cases:

##### 1. Static Tokens from Files (Recommended for Service Credentials)

Expand Down Expand Up @@ -392,7 +393,7 @@ mcp_servers:
Authorization: "kubernetes" # Uses user's k8s token from request auth
```

**Note:** Kubernetes token-based MCP authorization only works when Lightspeed Core Stack is configured with Kubernetes authentication (`authentication.k8s`). For any other authentication types, MCP servers configured with `Authorization: "kubernetes"` are removed from the available MCP servers list.
**Note:** Kubernetes token-based MCP authorization only works when Lightspeed Core Stack is configured with Kubernetes authentication (`authentication.module` is `k8s`) or `noop-with-token`. For any other authentication types, MCP servers configured with `Authorization: "kubernetes"` are removed from the available MCP servers list.

##### 3. Client-Provided Tokens (For Per-User Authentication)

Expand Down Expand Up @@ -420,6 +421,20 @@ curl -X POST "http://localhost:8080/v1/query" \

**Structure**: `MCP-HEADERS: {"<server-name>": {"<header-name>": "<header-value>", ...}, ...}`

##### 4. OAuth (For MCP Servers Requiring OAuth)

Use the special `"oauth"` keyword when the MCP server requires OAuth and the client will supply a token (e.g. via `MCP-HEADERS` after obtaining it from an OAuth flow):

```yaml
mcp_servers:
- name: "oauth-protected-service"
url: "https://mcp.example.com"
authorization_headers:
Authorization: "oauth" # Token provided via MCP-HEADERS (from OAuth flow)
```

When no token is provided for an OAuth-configured server, the service may respond with **401 Unauthorized** and a **`WWW-Authenticate`** header (probed from the MCP server). Clients can use this to drive an OAuth flow and then retry with the token in `MCP-HEADERS`.

##### Client-Authenticated MCP Servers Discovery

To help clients determine which MCP servers require client-provided tokens, use the **MCP Client Auth Options** endpoint:
Expand Down Expand Up @@ -481,6 +496,7 @@ mcp_servers:
| **Static File** | Service tokens, API keys | File path in config | Global (all users) | `"/var/secrets/token"` |
| **Kubernetes** | K8s service accounts | `"kubernetes"` keyword | Per-user (from auth) | `"kubernetes"` |
| **Client** | User-specific tokens | `"client"` keyword + HTTP header | Per-request | `"client"` |
| **OAuth** | OAuth-protected MCP servers | `"oauth"` keyword + HTTP header | Per-request (from OAuth flow) | `"oauth"` |

##### Important: Automatic Server Skipping

Expand All @@ -489,6 +505,7 @@ mcp_servers:
**Examples:**
- A server with `Authorization: "kubernetes"` will be skipped if the user's request doesn't include a Kubernetes token
- A server with `Authorization: "client"` will be skipped if no `MCP-HEADERS` are provided in the request
- A server with `Authorization: "oauth"` and no token in `MCP-HEADERS` may cause the API to return **401 Unauthorized** with a **`WWW-Authenticate`** header (so the client can perform OAuth and retry)
- A server with multiple headers will be skipped if **any** required header cannot be resolved

Skipped servers are logged as warnings. Check Lightspeed Core logs to see which servers were skipped and why.
Expand Down
2 changes: 1 addition & 1 deletion docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,7 @@ Useful resources:
| name | string | MCP server name that must be unique |
| provider_id | string | MCP provider identification |
| url | string | URL of the MCP server |
| authorization_headers | object | Headers to send to the MCP server. The map contains the header name and the path to a file containing the header value (secret). There are 2 special cases: 1. Usage of the kubernetes token in the header. To specify this use a string 'kubernetes' instead of the file path. 2. Usage of the client provided token in the header. To specify this use a string 'client' instead of the file path. |
| authorization_headers | object | Headers to send to the MCP server. The map contains the header name and the path to a file containing the header value (secret). There are 3 special cases: 1. Usage of the kubernetes token in the headeruse the string 'kubernetes' instead of the file path. 2. Usage of the client provided token in the header — use the string 'client' instead of the file path. 3. Usage of OAuth token (resolved at request time or 401 with WWW-Authenticate) — use the string 'oauth' instead of the file path. |
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

Minor grammar: "client provided" → "client-provided".

Static analysis flagged this. When used as a compound adjective before a noun, it should be hyphenated.

Suggested fix
-| authorization_headers | object | Headers to send to the MCP server. The map contains the header name and the path to a file containing the header value (secret). There are 3 special cases: 1. Usage of the kubernetes token in the header — use the string 'kubernetes' instead of the file path. 2. Usage of the client provided token in the header — use the string 'client' instead of the file path. 3. Usage of OAuth token (resolved at request time or 401 with WWW-Authenticate) — use the string 'oauth' instead of the file path. |
+| authorization_headers | object | Headers to send to the MCP server. The map contains the header name and the path to a file containing the header value (secret). There are 3 special cases: 1. Usage of the kubernetes token in the header — use the string 'kubernetes' instead of the file path. 2. Usage of the client-provided token in the header — use the string 'client' instead of the file path. 3. Usage of OAuth token (resolved at request time or 401 with WWW-Authenticate) — use the string 'oauth' instead of the file path. |
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
| authorization_headers | object | Headers to send to the MCP server. The map contains the header name and the path to a file containing the header value (secret). There are 3 special cases: 1. Usage of the kubernetes token in the header — use the string 'kubernetes' instead of the file path. 2. Usage of the client provided token in the header — use the string 'client' instead of the file path. 3. Usage of OAuth token (resolved at request time or 401 with WWW-Authenticate) — use the string 'oauth' instead of the file path. |
| authorization_headers | object | Headers to send to the MCP server. The map contains the header name and the path to a file containing the header value (secret). There are 3 special cases: 1. Usage of the kubernetes token in the header — use the string 'kubernetes' instead of the file path. 2. Usage of the client-provided token in the header — use the string 'client' instead of the file path. 3. Usage of OAuth token (resolved at request time or 401 with WWW-Authenticate) — use the string 'oauth' instead of the file path. |
🧰 Tools
🪛 LanguageTool

[grammar] ~374-~374: Use a hyphen to join words.
Context: ...of the file path. 2. Usage of the client provided token in the header — use the s...

(QB_NEW_EN_HYPHEN)

🤖 Prompt for AI Agents
In `@docs/config.md` at line 374, In the authorization_headers description update
the phrase "client provided token" to the hyphenated form "client-provided
token" (the clause that explains using the string 'client' instead of a file
path); edit the docs entry for authorization_headers to use "client-provided"
wherever it appears to correct the compound adjective usage.

| timeout | integer | Timeout in seconds for requests to the MCP server. If not specified, the default timeout from Llama Stack will be used. Note: This field is reserved for future use when Llama Stack adds timeout support. |


Expand Down
15 changes: 14 additions & 1 deletion src/app/endpoints/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

import logging
from typing import Annotated, Any
import requests
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Synchronous requests import in an async endpoint module.

This module defines async def endpoint handlers. Using the synchronous requests library blocks the event loop. Since the project already depends on aiohttp (per the library context), consider using that instead, or httpx.AsyncClient.

🤖 Prompt for AI Agents
In `@src/app/endpoints/tools.py` at line 5, The module imports the synchronous
requests library (requests) but defines async endpoint handlers, which will
block the event loop; replace the requests import with an async HTTP client
(e.g., aiohttp.ClientSession or httpx.AsyncClient), update any call sites that
use requests.get/post/etc. to use the async client with await (for example
create or accept an aiohttp.ClientSession and call await session.get(...)), and
ensure proper session lifecycle (use async context manager or
dependency-injected session) so all network I/O in your async endpoint functions
remains non-blocking.


from fastapi import APIRouter, Depends, HTTPException, Request
from llama_stack_client import APIConnectionError, BadRequestError
from llama_stack_client import APIConnectionError, BadRequestError, AuthenticationError

from authentication import get_auth_dependency
from authentication.interface import AuthTuple
Expand Down Expand Up @@ -89,6 +90,18 @@ async def tools_endpoint_handler( # pylint: disable=too-many-locals
except BadRequestError:
logger.error("Toolgroup %s is not found", toolgroup.identifier)
continue
except AuthenticationError as e:
logger.error("Authentication error: %s", e)
if toolgroup.mcp_endpoint:
resp = requests.get(toolgroup.mcp_endpoint.uri, timeout=10)
cause = f"MCP server at {toolgroup.mcp_endpoint.uri} requires OAuth"
error_response = UnauthorizedResponse(cause=cause)
raise HTTPException(
**error_response.model_dump(),
headers={"WWW-Authenticate": resp.headers["WWW-Authenticate"]},
) from e
error_response = UnauthorizedResponse(cause=str(e))
raise HTTPException(**error_response.model_dump()) from e
Comment on lines +93 to +104
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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Step 1: Check the function context around lines 93-104 in tools.py
echo "=== Function context around lines 93-104 in src/app/endpoints/tools.py ==="
sed -n '70,110p' src/app/endpoints/tools.py | cat -n

echo ""
echo "=== Check function signature ==="
sed -n '70,95p' src/app/endpoints/tools.py | grep -E "^(async )?def " | head -1

Repository: lightspeed-core/lightspeed-stack

Length of output: 2401


🏁 Script executed:

#!/bin/bash
# Step 2: Check project dependencies for async HTTP clients
echo "=== Check for async HTTP client dependencies ==="
if [ -f "pyproject.toml" ]; then
  grep -E "aiohttp|httpx" pyproject.toml
elif [ -f "requirements.txt" ]; then
  grep -E "aiohttp|httpx" requirements.txt
else
  find . -name "pyproject.toml" -o -name "requirements.txt" -o -name "setup.py" | head -3
fi

echo ""
echo "=== Check imports in tools.py ==="
head -30 src/app/endpoints/tools.py | cat -n

Repository: lightspeed-core/lightspeed-stack

Length of output: 1347


🏁 Script executed:

#!/bin/bash
# Step 3: Search for requests.get usage in src/**/*.py
echo "=== All requests.get() calls in src/ ==="
rg -n "requests\." --type=py src/ | head -20

Repository: lightspeed-core/lightspeed-stack

Length of output: 1610


🏁 Script executed:

#!/bin/bash
# Step 4: Check src/utils/responses.py for get_mcp_tools pattern
echo "=== Check get_mcp_tools function in src/utils/responses.py ==="
rg -n -B2 -A8 "def get_mcp_tools" src/utils/responses.py

Repository: lightspeed-core/lightspeed-stack

Length of output: 531


🏁 Script executed:

#!/bin/bash
# Check if get_mcp_tools is async and full context around line 398
echo "=== Check if get_mcp_tools is async ==="
sed -n '318,325p' src/utils/responses.py | cat -n

echo ""
echo "=== Full context around line 398 in responses.py ==="
sed -n '390,410p' src/utils/responses.py | cat -n

echo ""
echo "=== Check which function contains line 398 ==="
sed -n '380,400p' src/utils/responses.py | grep -E "^(async )?def " | tail -1

Repository: lightspeed-core/lightspeed-stack

Length of output: 1712


🏁 Script executed:

#!/bin/bash
# Check if there are any await calls or async patterns near line 398
echo "=== Context around requests.get in responses.py with function boundaries ==="
sed -n '1,400p' src/utils/responses.py | tail -100 | head -50

Repository: lightspeed-core/lightspeed-stack

Length of output: 1901


🏁 Script executed:

#!/bin/bash
# Check where get_mcp_tools is called from
echo "=== Where is get_mcp_tools called? ==="
rg -n "get_mcp_tools" src/ --type=py

echo ""
echo "=== Check if those call sites are in async functions ==="
# Check the call in responses.py
rg -n -B5 "get_mcp_tools" src/ --type=py | grep -E "(async def|def )" | head -5

Repository: lightspeed-core/lightspeed-stack

Length of output: 661


🏁 Script executed:

#!/bin/bash
# Verify the full handler function signature in tools.py to confirm it's async
echo "=== Find the full async endpoint function in tools.py ==="
rg -n "^async def\|^@router" src/app/endpoints/tools.py | head -10

echo ""
echo "=== Get the specific endpoint that contains the blocking code ==="
sed -n '60,95p' src/app/endpoints/tools.py | cat -n | tail -20

Repository: lightspeed-core/lightspeed-stack

Length of output: 1126


🏁 Script executed:

#!/bin/bash
# Find the complete function definition that contains the blocking code
echo "=== Find the router decorator and function def in tools.py ==="
sed -n '35,65p' src/app/endpoints/tools.py | cat -n

echo ""
echo "=== Also check for `@router.get` or similar ==="
rg -n "@router" src/app/endpoints/tools.py | head -5

Repository: lightspeed-core/lightspeed-stack

Length of output: 1539


🏁 Script executed:

#!/bin/bash
# Check the call site in responses.py line 188 - is that function async?
echo "=== Check function containing line 188 in responses.py ==="
sed -n '170,195p' src/utils/responses.py | cat -n

echo ""
echo "=== Check the rlsapi_v1.py call site line 268 ==="
sed -n '250,275p' src/app/endpoints/rlsapi_v1.py | cat -n

Repository: lightspeed-core/lightspeed-stack

Length of output: 2547


Blocking I/O in async handler and unhandled failure modes in the OAuth probe.

Three critical issues in this block:

  1. Blocking call on the event loop: requests.get() is synchronous and will block the entire async event loop for up to 10 seconds. Use an async HTTP client (aiohttp, httpx.AsyncClient) or run it in an executor. (aiohttp is already available in dependencies.)

  2. KeyError if WWW-Authenticate is missing: resp.headers["WWW-Authenticate"] (line 101) will raise KeyError if the MCP server doesn't return that header, resulting in an unhandled 500. Use .get() with a fallback.

  3. No error handling on the probe request: requests.get() can raise ConnectionError, Timeout, etc. If the MCP server is unreachable, this surfaces as an unhandled 500 instead of a meaningful error.

The same pattern exists in src/utils/responses.py:398 (get_mcp_tools) and should be fixed there too.

Proposed fix (minimal — still uses requests; async migration recommended separately)
         except AuthenticationError as e:
             logger.error("Authentication error: %s", e)
             if toolgroup.mcp_endpoint:
-                resp = requests.get(toolgroup.mcp_endpoint.uri, timeout=10)
-                cause = f"MCP server at {toolgroup.mcp_endpoint.uri} requires OAuth"
-                error_response = UnauthorizedResponse(cause=cause)
-                raise HTTPException(
-                    **error_response.model_dump(),
-                    headers={"WWW-Authenticate": resp.headers["WWW-Authenticate"]},
-                ) from e
+                www_authenticate = ""
+                try:
+                    resp = requests.get(toolgroup.mcp_endpoint.uri, timeout=10)
+                    www_authenticate = resp.headers.get("WWW-Authenticate", "")
+                except requests.RequestException:
+                    logger.warning(
+                        "Failed to probe MCP server %s for WWW-Authenticate header",
+                        toolgroup.mcp_endpoint.uri,
+                    )
+                cause = f"MCP server at {toolgroup.mcp_endpoint.uri} requires OAuth"
+                error_response = UnauthorizedResponse(cause=cause)
+                headers = {"WWW-Authenticate": www_authenticate} if www_authenticate else {}
+                raise HTTPException(
+                    **error_response.model_dump(),
+                    headers=headers,
+                ) from e
             error_response = UnauthorizedResponse(cause=str(e))
             raise HTTPException(**error_response.model_dump()) from e

Per coding guidelines, "Use async def for I/O operations and external API calls".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
except AuthenticationError as e:
logger.error("Authentication error: %s", e)
if toolgroup.mcp_endpoint:
resp = requests.get(toolgroup.mcp_endpoint.uri, timeout=10)
cause = f"MCP server at {toolgroup.mcp_endpoint.uri} requires OAuth"
error_response = UnauthorizedResponse(cause=cause)
raise HTTPException(
**error_response.model_dump(),
headers={"WWW-Authenticate": resp.headers["WWW-Authenticate"]},
) from e
error_response = UnauthorizedResponse(cause=str(e))
raise HTTPException(**error_response.model_dump()) from e
except AuthenticationError as e:
logger.error("Authentication error: %s", e)
if toolgroup.mcp_endpoint:
www_authenticate = ""
try:
resp = requests.get(toolgroup.mcp_endpoint.uri, timeout=10)
www_authenticate = resp.headers.get("WWW-Authenticate", "")
except requests.RequestException:
logger.warning(
"Failed to probe MCP server %s for WWW-Authenticate header",
toolgroup.mcp_endpoint.uri,
)
cause = f"MCP server at {toolgroup.mcp_endpoint.uri} requires OAuth"
error_response = UnauthorizedResponse(cause=cause)
headers = {"WWW-Authenticate": www_authenticate} if www_authenticate else {}
raise HTTPException(
**error_response.model_dump(),
headers=headers,
) from e
error_response = UnauthorizedResponse(cause=str(e))
raise HTTPException(**error_response.model_dump()) from e
🤖 Prompt for AI Agents
In `@src/app/endpoints/tools.py` around lines 93 - 104, The block catching
AuthenticationError is performing a blocking synchronous probe and not guarding
against probe failures or missing headers; update the probe to use an async HTTP
client (e.g., aiohttp) and await the request instead of requests.get, wrap the
probe in try/except to catch connection, timeout, and client errors (e.g.,
aiohttp.ClientError, asyncio.TimeoutError) and fall back to a safe
UnauthorizedResponse when the probe fails, and when constructing the
HTTPException use resp.headers.get("WWW-Authenticate") with a sensible default
(or omit the header if absent) to avoid KeyError; apply the same
async/non-blocking error-handling pattern to the get_mcp_tools function in
src/utils/responses.py.

except APIConnectionError as e:
logger.error("Unable to connect to Llama Stack: %s", e)
response = ServiceUnavailableResponse(
Expand Down
1 change: 1 addition & 0 deletions src/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@
# MCP authorization header special values
MCP_AUTH_KUBERNETES = "kubernetes"
MCP_AUTH_CLIENT = "client"
MCP_AUTH_OAUTH = "oauth"

# default RAG tool value
DEFAULT_RAG_TOOL = "file_search"
Expand Down
4 changes: 3 additions & 1 deletion src/models/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -505,11 +505,13 @@ class ModelContextProtocolServer(ConfigurationBase):
"Headers to send to the MCP server. "
"The map contains the header name and the path to a file containing "
"the header value (secret). "
"There are 2 special cases: "
"There are 3 special cases: "
"1. Usage of the kubernetes token in the header. "
"To specify this use a string 'kubernetes' instead of the file path. "
"2. Usage of the client provided token in the header. "
"To specify this use a string 'client' instead of the file path."
"3. Usage of the oauth token in the header. "
"To specify this use a string 'oauth' instead of the file path. "
Comment on lines 512 to +514
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

Missing space between special cases 2 and 3 in the description string.

Line 512 ends without a trailing space, so the concatenated text reads "...file path.3. Usage of the oauth...". Add a trailing space (or leading space on Line 513) to match the formatting of case 1→2.

Proposed fix
-            "To specify this use a string 'client' instead of the file path."
-            "3. Usage of the oauth token in the header. "
+            "To specify this use a string 'client' instead of the file path. "
+            "3. Usage of the oauth token in the header. "
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"To specify this use a string 'client' instead of the file path."
"3. Usage of the oauth token in the header. "
"To specify this use a string 'oauth' instead of the file path. "
"To specify this use a string 'client' instead of the file path. "
"3. Usage of the oauth token in the header. "
"To specify this use a string 'oauth' instead of the file path. "
🤖 Prompt for AI Agents
In `@src/models/config.py` around lines 512 - 514, The concatenated description
string for the special cases is missing a space between the end of the "client"
sentence and the start of "3. Usage of the oauth..." sentence; update the string
fragments (the literal containing "To specify this use a string 'client' instead
of the file path." or the immediately following fragment starting "3. Usage of
the oauth token in the header.") so there is a space either appended to the
first fragment or prefixed to the second fragment to ensure the final
description reads "...file path. 3. Usage of the oauth..." (refer to the config
description string that includes the "'client'" and "'oauth'" special-case
text).

),
)

Expand Down
14 changes: 12 additions & 2 deletions src/utils/mcp_auth_headers.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ def resolve_authorization_headers(

Parameters:
authorization_headers: Map of header names to secret locations or special keywords.
- If value is "kubernetes": leave is unchanged. We substitute it during request.
- If value is "client": leave it unchanged. . We substitute it during request.
- If value is "kubernetes": leave unchanged. We substitute it during request.
- If value is "client": leave unchanged. We substitute it during request.
- If value is "oauth": leave unchanged; if no token is provided, a 401 with
WWW-Authenticate may be forwarded from the MCP server.
- Otherwise: Treat as file path and read the secret from that file

Returns:
Expand Down Expand Up @@ -55,6 +57,14 @@ def resolve_authorization_headers(
"Header %s will use client-provided token (resolved at request time)",
header_name,
)
elif value == constants.MCP_AUTH_OAUTH:
# Special case: Keep oauth keyword; if no token provided, probe endpoint
# and forward 401 WWW-Authenticate for client-driven OAuth flow
resolved[header_name] = constants.MCP_AUTH_OAUTH
logger.debug(
"Header %s will use OAuth token (resolved at request time or 401)",
header_name,
)
else:
# Regular case: Read secret from file path
secret_path = Path(value).expanduser()
Expand Down
33 changes: 32 additions & 1 deletion src/utils/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import logging
from typing import Any, Optional, cast

import requests
from fastapi import HTTPException
from llama_stack_api.openai_responses import (
OpenAIResponseObject,
Expand All @@ -28,6 +29,7 @@
from models.responses import (
InternalServerErrorResponse,
ServiceUnavailableResponse,
UnauthorizedResponse,
)
from utils.prompts import get_system_prompt, get_topic_summary_system_prompt
from utils.query import (
Expand Down Expand Up @@ -313,7 +315,7 @@ def get_rag_tools(vector_store_ids: list[str]) -> Optional[list[dict[str, Any]]]
]


def get_mcp_tools(
def get_mcp_tools( # pylint: disable=too-many-return-statements
mcp_servers: list[ModelContextProtocolServer],
token: str | None = None,
mcp_headers: Optional[McpHeaders] = None,
Expand All @@ -327,6 +329,10 @@ def get_mcp_tools(

Returns:
List of MCP tool definitions with server details and optional auth headers

Raises:
HTTPException: 401 with WWW-Authenticate header when an MCP server uses OAuth,
no headers are passed, and the server responds with 401 and WWW-Authenticate.
"""

def _get_token_value(original: str, header: str) -> str | None:
Expand All @@ -345,6 +351,14 @@ def _get_token_value(original: str, header: str) -> str | None:
if c_headers is None:
return None
return c_headers.get(header, None)
case constants.MCP_AUTH_OAUTH:
# use oauth token
if mcp_headers is None:
return None
c_headers = mcp_headers.get(mcp_server.name, None)
if c_headers is None:
return None
return c_headers.get(header, None)
case _:
# use provided
return original
Expand Down Expand Up @@ -372,6 +386,23 @@ def _get_token_value(original: str, header: str) -> str | None:
if mcp_server.authorization_headers and len(headers) != len(
mcp_server.authorization_headers
):
# If OAuth was required and no headers passed, probe endpoint and forward
# 401 with WWW-Authenticate so the client can perform OAuth
uses_oauth = (
constants.MCP_AUTH_OAUTH
in mcp_server.resolved_authorization_headers.values()
)
if uses_oauth and (
mcp_headers is None or not mcp_headers.get(mcp_server.name)
):
resp = requests.get(mcp_server.url, timeout=10)
error_response = UnauthorizedResponse(
cause=f"MCP server at {mcp_server.url} requires OAuth authentication",
)
raise HTTPException(
**error_response.model_dump(),
headers={"WWW-Authenticate": resp.headers["WWW-Authenticate"]},
)
Comment on lines +389 to +405
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 | 🔴 Critical

Missing error handling: requests.get() can fail, and WWW-Authenticate header may be absent.

Several unhandled failure modes:

  1. requests.get(mcp_server.url, timeout=10) (Line 398) — can raise requests.ConnectionError, requests.Timeout, etc. if the MCP server is unreachable. This will surface as an unhandled 500.
  2. resp.headers["WWW-Authenticate"] (Line 404) — raises KeyError if the MCP server doesn't include this header (e.g., returns 200, or a 401 without it).
  3. Blocking I/O in async contextget_mcp_tools is called from the async prepare_tools (line 188). The synchronous requests.get() blocks the event loop. Consider using httpx.AsyncClient or running in an executor. The same issue exists in src/app/endpoints/tools.py line ~98.

At minimum, wrap the probe in a try/except and use .get() for the header:

Proposed fix for error handling
             if uses_oauth and (
                 mcp_headers is None or not mcp_headers.get(mcp_server.name)
             ):
-                resp = requests.get(mcp_server.url, timeout=10)
-                error_response = UnauthorizedResponse(
-                    cause=f"MCP server at {mcp_server.url} requires OAuth authentication",
-                )
-                raise HTTPException(
-                    **error_response.model_dump(),
-                    headers={"WWW-Authenticate": resp.headers["WWW-Authenticate"]},
-                )
+                error_response = UnauthorizedResponse(
+                    cause=f"MCP server at {mcp_server.url} requires OAuth authentication",
+                )
+                headers: dict[str, str] = {}
+                try:
+                    resp = requests.get(mcp_server.url, timeout=10)
+                    www_auth = resp.headers.get("WWW-Authenticate")
+                    if www_auth:
+                        headers["WWW-Authenticate"] = www_auth
+                except requests.RequestException:
+                    logger.warning(
+                        "Failed to probe MCP server %s for WWW-Authenticate header",
+                        mcp_server.url,
+                    )
+                raise HTTPException(
+                    **error_response.model_dump(),
+                    headers=headers if headers else None,
+                )

As per coding guidelines: Use async def for I/O operations and external API calls. The synchronous requests.get call in this code path blocks the event loop. Consider using an async HTTP client for this probe request, or at least running it in an executor.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# If OAuth was required and no headers passed, probe endpoint and forward
# 401 with WWW-Authenticate so the client can perform OAuth
uses_oauth = (
constants.MCP_AUTH_OAUTH
in mcp_server.resolved_authorization_headers.values()
)
if uses_oauth and (
mcp_headers is None or not mcp_headers.get(mcp_server.name)
):
resp = requests.get(mcp_server.url, timeout=10)
error_response = UnauthorizedResponse(
cause=f"MCP server at {mcp_server.url} requires OAuth authentication",
)
raise HTTPException(
**error_response.model_dump(),
headers={"WWW-Authenticate": resp.headers["WWW-Authenticate"]},
)
# If OAuth was required and no headers passed, probe endpoint and forward
# 401 with WWW-Authenticate so the client can perform OAuth
uses_oauth = (
constants.MCP_AUTH_OAUTH
in mcp_server.resolved_authorization_headers.values()
)
if uses_oauth and (
mcp_headers is None or not mcp_headers.get(mcp_server.name)
):
error_response = UnauthorizedResponse(
cause=f"MCP server at {mcp_server.url} requires OAuth authentication",
)
headers: dict[str, str] = {}
try:
resp = requests.get(mcp_server.url, timeout=10)
www_auth = resp.headers.get("WWW-Authenticate")
if www_auth:
headers["WWW-Authenticate"] = www_auth
except requests.RequestException:
logger.warning(
"Failed to probe MCP server %s for WWW-Authenticate header",
mcp_server.url,
)
raise HTTPException(
**error_response.model_dump(),
headers=headers if headers else None,
)
🤖 Prompt for AI Agents
In `@src/utils/responses.py` around lines 389 - 405, The probe using requests.get
in the get_mcp_tools logic blocks the async event loop and lacks error handling
for connection/timeouts and missing WWW-Authenticate header; replace the
synchronous probe with an async HTTP call (e.g., httpx.AsyncClient) or run
requests.get in an executor, wrap the call in try/except to catch request
exceptions (ConnectionError, Timeout, HTTPError) and handle them gracefully, and
when building the HTTPException use resp.headers.get("WWW-Authenticate") (only
include the header if present) so you don't KeyError; update the code paths in
get_mcp_tools and callers (prepare_tools) accordingly to await the async probe
or handle executor futures.

logger.warning(
"Skipping MCP server %s: required %d auth headers but only resolved %d",
mcp_server.name,
Expand Down
86 changes: 85 additions & 1 deletion tests/unit/app/endpoints/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import pytest
from fastapi import HTTPException
from llama_stack_client import APIConnectionError, BadRequestError
from llama_stack_client import APIConnectionError, AuthenticationError, BadRequestError
from pytest_mock import MockerFixture, MockType

# Import the function directly to bypass decorators
Expand Down Expand Up @@ -628,3 +628,87 @@ async def test_tools_endpoint_general_exception(
# Call the endpointt and expect the exception to propagate (not caught)
with pytest.raises(Exception, match="Unexpected error"):
await tools.tools_endpoint_handler.__wrapped__(mock_request, mock_auth) # type: ignore


@pytest.mark.asyncio
async def test_tools_endpoint_authentication_error_with_mcp_endpoint(
mocker: MockerFixture,
mock_configuration: Configuration, # pylint: disable=redefined-outer-name
) -> None:
"""Test tools endpoint raises 401 with WWW-Authenticate when MCP server requires OAuth."""
app_config = AppConfig()
app_config._configuration = mock_configuration
mocker.patch("app.endpoints.tools.configuration", app_config)
mocker.patch("app.endpoints.tools.authorize", lambda _: lambda func: func)

mock_client_holder = mocker.patch("app.endpoints.tools.AsyncLlamaStackClientHolder")
mock_client = mocker.AsyncMock()
mock_client_holder.return_value.get_client.return_value = mock_client

mock_toolgroup = mocker.Mock()
mock_toolgroup.identifier = "mcp-tools"
mock_toolgroup.mcp_endpoint = mocker.Mock()
mock_toolgroup.mcp_endpoint.uri = "http://localhost:3000"
mock_client.toolgroups.list.return_value = [mock_toolgroup]

auth_error = AuthenticationError(
message="MCP server requires OAuth",
response=mocker.Mock(request=None),
body=None,
)
mock_client.tools.list.side_effect = auth_error

mock_resp = mocker.Mock()
mock_resp.headers = {"WWW-Authenticate": 'Bearer error="invalid_token"'}
mocker.patch("app.endpoints.tools.requests.get", return_value=mock_resp)

mock_request = mocker.Mock()
mock_auth = MOCK_AUTH

with pytest.raises(HTTPException) as exc_info:
await tools.tools_endpoint_handler.__wrapped__(mock_request, mock_auth)

assert exc_info.value.status_code == 401
assert exc_info.value.headers is not None
assert (
exc_info.value.headers.get("WWW-Authenticate") == 'Bearer error="invalid_token"'
)


@pytest.mark.asyncio
async def test_tools_endpoint_authentication_error_without_mcp_endpoint(
mocker: MockerFixture,
mock_configuration: Configuration, # pylint: disable=redefined-outer-name
) -> None:
"""Test tools endpoint raises 401 without WWW-Authenticate when no mcp_endpoint."""
app_config = AppConfig()
app_config._configuration = mock_configuration
mocker.patch("app.endpoints.tools.configuration", app_config)
mocker.patch("app.endpoints.tools.authorize", lambda _: lambda func: func)

mock_client_holder = mocker.patch("app.endpoints.tools.AsyncLlamaStackClientHolder")
mock_client = mocker.AsyncMock()
mock_client_holder.return_value.get_client.return_value = mock_client

mock_toolgroup = mocker.Mock()
mock_toolgroup.identifier = "mcp-tools"
mock_toolgroup.mcp_endpoint = None
mock_client.toolgroups.list.return_value = [mock_toolgroup]

auth_error = AuthenticationError(
message="Authentication failed",
response=mocker.Mock(request=None),
body=None,
)
mock_client.tools.list.side_effect = auth_error

mock_request = mocker.Mock()
mock_auth = MOCK_AUTH

with pytest.raises(HTTPException) as exc_info:
await tools.tools_endpoint_handler.__wrapped__(mock_request, mock_auth)

assert exc_info.value.status_code == 401
detail = exc_info.value.detail
assert isinstance(detail, dict)
assert detail.get("cause") == "Authentication failed"
11 changes: 11 additions & 0 deletions tests/unit/models/config/test_model_context_protocol_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,17 @@ def test_model_context_protocol_server_client_special_case() -> None:
assert mcp.authorization_headers == {"Authorization": "client"}


def test_model_context_protocol_server_oauth_special_case() -> None:
"""Test ModelContextProtocolServer with oauth special case."""
mcp = ModelContextProtocolServer(
name="oauth-server",
url="http://localhost:8080",
authorization_headers={"Authorization": "oauth"},
)
assert mcp is not None
assert mcp.authorization_headers == {"Authorization": "oauth"}


def test_configuration_mcp_servers_with_mixed_auth_headers(tmp_path: Path) -> None:
"""
Test Configuration with MCP servers having mixed authorization headers.
Expand Down
9 changes: 9 additions & 0 deletions tests/unit/utils/test_mcp_auth_headers.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,15 @@ def test_resolve_authorization_headers_kubernetes_token() -> None:
assert result == {"Authorization": "kubernetes"}


def test_resolve_authorization_headers_oauth_token() -> None:
"""Test that oauth keyword is preserved."""
headers = {"Authorization": "oauth"}
result = resolve_authorization_headers(headers)

# Should keep "oauth" as-is for later substitution or 401 WWW-Authenticate flow
assert result == {"Authorization": "oauth"}


def test_resolve_authorization_headers_multiple_headers(tmp_path: Path) -> None:
"""Test resolving multiple authorization headers."""
# Create multiple secret files
Expand Down
26 changes: 26 additions & 0 deletions tests/unit/utils/test_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,32 @@ def test_get_mcp_tools_includes_server_without_auth(self) -> None:
assert tools[0]["server_label"] == "public-server"
assert "headers" not in tools[0]

def test_get_mcp_tools_oauth_no_headers_raises_401_with_www_authenticate(
self, mocker: MockerFixture
) -> None:
"""Test get_mcp_tools raises 401 with WWW-Authenticate when OAuth required and no headers."""
servers = [
ModelContextProtocolServer(
name="oauth-server",
url="http://localhost:3000",
authorization_headers={"Authorization": "oauth"},
),
]

mock_resp = mocker.Mock()
mock_resp.headers = {"WWW-Authenticate": 'Bearer error="invalid_token"'}
mocker.patch("utils.responses.requests.get", return_value=mock_resp)

with pytest.raises(HTTPException) as exc_info:
get_mcp_tools(servers, token=None, mcp_headers=None)

assert exc_info.value.status_code == 401
assert exc_info.value.headers is not None
assert (
exc_info.value.headers.get("WWW-Authenticate")
== 'Bearer error="invalid_token"'
)


class TestGetTopicSummary:
"""Tests for get_topic_summary function."""
Expand Down
Loading