Skip to content

Commit a263db1

Browse files
authored
Merge pull request #1156 from jrobertboos/lcore-1246
LCORE-1246: Add OAuth authentication method for MCP servers
2 parents d5b791c + 9d14570 commit a263db1

15 files changed

Lines changed: 310 additions & 33 deletions

File tree

README.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ The service includes comprehensive user data collection capabilities for various
3434
* [1. Static Tokens from Files (Recommended for Service Credentials)](#1-static-tokens-from-files-recommended-for-service-credentials)
3535
* [2. Kubernetes Service Account Tokens (For K8s Deployments)](#2-kubernetes-service-account-tokens-for-k8s-deployments)
3636
* [3. Client-Provided Tokens (For Per-User Authentication)](#3-client-provided-tokens-for-per-user-authentication)
37+
* [4. OAuth (For MCP Servers Requiring OAuth)](#4-oauth-for-mcp-servers-requiring-oauth)
3738
* [Client-Authenticated MCP Servers Discovery](#client-authenticated-mcp-servers-discovery)
3839
* [Combining Authentication Methods](#combining-authentication-methods)
3940
* [Authentication Method Comparison](#authentication-method-comparison)
@@ -355,7 +356,7 @@ In addition to the basic configuration above, you can configure authentication h
355356

356357
#### Configuring MCP Server Authentication
357358

358-
Lightspeed Core Stack supports three methods for authenticating with MCP servers, each suited for different use cases:
359+
Lightspeed Core Stack supports four methods for authenticating with MCP servers, each suited for different use cases:
359360

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

@@ -392,7 +393,7 @@ mcp_servers:
392393
Authorization: "kubernetes" # Uses user's k8s token from request auth
393394
```
394395

395-
**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.
396+
**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.
396397

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

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

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

424+
##### 4. OAuth (For MCP Servers Requiring OAuth)
425+
426+
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):
427+
428+
```yaml
429+
mcp_servers:
430+
- name: "oauth-protected-service"
431+
url: "https://mcp.example.com"
432+
authorization_headers:
433+
Authorization: "oauth" # Token provided via MCP-HEADERS (from OAuth flow)
434+
```
435+
436+
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`.
437+
423438
##### Client-Authenticated MCP Servers Discovery
424439

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

485501
##### Important: Automatic Server Skipping
486502

@@ -489,6 +505,7 @@ mcp_servers:
489505
**Examples:**
490506
- A server with `Authorization: "kubernetes"` will be skipped if the user's request doesn't include a Kubernetes token
491507
- A server with `Authorization: "client"` will be skipped if no `MCP-HEADERS` are provided in the request
508+
- 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)
492509
- A server with multiple headers will be skipped if **any** required header cannot be resolved
493510

494511
Skipped servers are logged as warnings. Check Lightspeed Core logs to see which servers were skipped and why.

docs/config.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,7 @@ Useful resources:
372372
| name | string | MCP server name that must be unique |
373373
| provider_id | string | MCP provider identification |
374374
| url | string | URL of the MCP server |
375-
| 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. |
375+
| 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. |
376376
| 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. |
377377

378378

src/app/endpoints/rlsapi_v1.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,7 @@ async def infer_endpoint(
305305
input_source = infer_request.get_input_source()
306306
instructions = _build_instructions(infer_request.context.systeminfo)
307307
model_id = _get_default_model_id()
308-
mcp_tools = get_mcp_tools(configuration.mcp_servers)
308+
mcp_tools = await get_mcp_tools(configuration.mcp_servers)
309309
logger.debug(
310310
"Request %s: Combined input source length: %d", request_id, len(input_source)
311311
)

src/app/endpoints/tools.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from typing import Annotated, Any
44

55
from fastapi import APIRouter, Depends, HTTPException, Request
6-
from llama_stack_client import APIConnectionError, BadRequestError
6+
from llama_stack_client import APIConnectionError, BadRequestError, AuthenticationError
77

88
from authentication import get_auth_dependency
99
from authentication.interface import AuthTuple
@@ -19,6 +19,7 @@
1919
UnauthorizedResponse,
2020
)
2121
from utils.endpoints import check_configuration_loaded
22+
from utils.mcp_oauth_probe import probe_mcp_oauth_and_raise_401
2223
from utils.tool_formatter import format_tools_list
2324
from log import get_logger
2425

@@ -39,7 +40,7 @@
3940

4041
@router.get("/tools", responses=tools_responses)
4142
@authorize(Action.GET_TOOLS)
42-
async def tools_endpoint_handler( # pylint: disable=too-many-locals
43+
async def tools_endpoint_handler( # pylint: disable=too-many-locals,too-many-statements
4344
request: Request,
4445
auth: Annotated[AuthTuple, Depends(get_auth_dependency())],
4546
) -> ToolsResponse:
@@ -89,6 +90,14 @@ async def tools_endpoint_handler( # pylint: disable=too-many-locals
8990
except BadRequestError:
9091
logger.error("Toolgroup %s is not found", toolgroup.identifier)
9192
continue
93+
except AuthenticationError as e:
94+
logger.error("Authentication error: %s", e)
95+
if toolgroup.mcp_endpoint:
96+
await probe_mcp_oauth_and_raise_401(
97+
toolgroup.mcp_endpoint.uri, chain_from=e
98+
)
99+
error_response = UnauthorizedResponse(cause=str(e))
100+
raise HTTPException(**error_response.model_dump()) from e
92101
except APIConnectionError as e:
93102
logger.error("Unable to connect to Llama Stack: %s", e)
94103
response = ServiceUnavailableResponse(

src/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@
125125
# MCP authorization header special values
126126
MCP_AUTH_KUBERNETES = "kubernetes"
127127
MCP_AUTH_CLIENT = "client"
128+
MCP_AUTH_OAUTH = "oauth"
128129

129130
# default RAG tool value
130131
DEFAULT_RAG_TOOL = "file_search"

src/models/config.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -505,11 +505,13 @@ class ModelContextProtocolServer(ConfigurationBase):
505505
"Headers to send to the MCP server. "
506506
"The map contains the header name and the path to a file containing "
507507
"the header value (secret). "
508-
"There are 2 special cases: "
508+
"There are 3 special cases: "
509509
"1. Usage of the kubernetes token in the header. "
510510
"To specify this use a string 'kubernetes' instead of the file path. "
511-
"2. Usage of the client provided token in the header. "
512-
"To specify this use a string 'client' instead of the file path."
511+
"2. Usage of the client-provided token in the header. "
512+
"To specify this use a string 'client' instead of the file path. "
513+
"3. Usage of the oauth token in the header. "
514+
"To specify this use a string 'oauth' instead of the file path. "
513515
),
514516
)
515517

src/utils/mcp_auth_headers.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@ def resolve_authorization_headers(
1616
1717
Parameters:
1818
authorization_headers: Map of header names to secret locations or special keywords.
19-
- If value is "kubernetes": leave is unchanged. We substitute it during request.
20-
- If value is "client": leave it unchanged. . We substitute it during request.
19+
- If value is "kubernetes": leave unchanged. We substitute it during request.
20+
- If value is "client": leave unchanged. We substitute it during request.
21+
- If value is "oauth": leave unchanged; if no token is provided, a 401 with
22+
WWW-Authenticate may be forwarded from the MCP server.
2123
- Otherwise: Treat as file path and read the secret from that file
2224
2325
Returns:
@@ -55,6 +57,14 @@ def resolve_authorization_headers(
5557
"Header %s will use client-provided token (resolved at request time)",
5658
header_name,
5759
)
60+
elif value == constants.MCP_AUTH_OAUTH:
61+
# Special case: Keep oauth keyword; if no token provided, probe endpoint
62+
# and forward 401 WWW-Authenticate for client-driven OAuth flow
63+
resolved[header_name] = constants.MCP_AUTH_OAUTH
64+
logger.debug(
65+
"Header %s will use OAuth token (resolved at request time or 401)",
66+
header_name,
67+
)
5868
else:
5969
# Regular case: Read secret from file path
6070
secret_path = Path(value).expanduser()

src/utils/mcp_oauth_probe.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""Probe MCP server for OAuth and raise 401 with WWW-Authenticate when required."""
2+
3+
import aiohttp
4+
from fastapi import HTTPException
5+
6+
from models.responses import UnauthorizedResponse
7+
8+
from log import get_logger
9+
10+
logger = get_logger(__name__)
11+
12+
13+
async def probe_mcp_oauth_and_raise_401(
14+
url: str,
15+
chain_from: BaseException | None = None,
16+
) -> None:
17+
"""Probe MCP endpoint and raise 401 so the client can perform OAuth.
18+
19+
Performs an async GET to the given URL to obtain a WWW-Authenticate header,
20+
then raises HTTPException with status 401 and that header. If the probe
21+
fails (connection error, timeout), raises 401 without the header.
22+
23+
Args:
24+
url: MCP server URL to probe.
25+
chain_from: Exception to chain the HTTPException from when
26+
the probe succeeds (e.g. the original AuthenticationError).
27+
28+
Returns:
29+
None. Always raises an HTTPException.
30+
31+
Raises:
32+
HTTPException: 401 with WWW-Authenticate when the probe succeeds, or
33+
401 without the header when the probe fails.
34+
"""
35+
cause = f"MCP server at {url} requires OAuth"
36+
error_response = UnauthorizedResponse(cause=cause)
37+
try:
38+
timeout = aiohttp.ClientTimeout(total=10)
39+
async with aiohttp.ClientSession(timeout=timeout) as session:
40+
async with session.get(url) as resp:
41+
www_auth = resp.headers.get("WWW-Authenticate")
42+
if www_auth is None:
43+
logger.warning("No WWW-Authenticate header received from %s", url)
44+
raise HTTPException(**error_response.model_dump()) from chain_from
45+
raise HTTPException(
46+
**error_response.model_dump(),
47+
headers={"WWW-Authenticate": www_auth},
48+
) from chain_from
49+
except (aiohttp.ClientError, TimeoutError) as probe_err:
50+
logger.warning("OAuth probe failed for %s: %s", url, probe_err)
51+
raise HTTPException(**error_response.model_dump()) from probe_err

src/utils/responses.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
InternalServerErrorResponse,
2929
ServiceUnavailableResponse,
3030
)
31+
from utils.mcp_oauth_probe import probe_mcp_oauth_and_raise_401
3132
from utils.prompts import get_system_prompt, get_topic_summary_system_prompt
3233
from utils.query import (
3334
evaluate_model_hints,
@@ -183,7 +184,7 @@ async def prepare_tools(
183184
toolgroups.extend(rag_tools)
184185

185186
# Add MCP server tools
186-
mcp_tools = get_mcp_tools(config.mcp_servers, token, mcp_headers)
187+
mcp_tools = await get_mcp_tools(config.mcp_servers, token, mcp_headers)
187188
if mcp_tools:
188189
toolgroups.extend(mcp_tools)
189190
logger.debug(
@@ -332,7 +333,7 @@ def get_rag_tools(vector_store_ids: list[str]) -> Optional[list[dict[str, Any]]]
332333
]
333334

334335

335-
def get_mcp_tools(
336+
async def get_mcp_tools( # pylint: disable=too-many-return-statements,too-many-locals
336337
mcp_servers: list[ModelContextProtocolServer],
337338
token: str | None = None,
338339
mcp_headers: Optional[McpHeaders] = None,
@@ -346,6 +347,10 @@ def get_mcp_tools(
346347
347348
Returns:
348349
List of MCP tool definitions with server details and optional auth headers
350+
351+
Raises:
352+
HTTPException: 401 with WWW-Authenticate header when an MCP server uses OAuth,
353+
no headers are passed, and the server responds with 401 and WWW-Authenticate.
349354
"""
350355

351356
def _get_token_value(original: str, header: str) -> str | None:
@@ -364,6 +369,14 @@ def _get_token_value(original: str, header: str) -> str | None:
364369
if c_headers is None:
365370
return None
366371
return c_headers.get(header, None)
372+
case constants.MCP_AUTH_OAUTH:
373+
# use oauth token
374+
if mcp_headers is None:
375+
return None
376+
c_headers = mcp_headers.get(mcp_server.name, None)
377+
if c_headers is None:
378+
return None
379+
return c_headers.get(header, None)
367380
case _:
368381
# use provided
369382
return original
@@ -391,6 +404,16 @@ def _get_token_value(original: str, header: str) -> str | None:
391404
if mcp_server.authorization_headers and len(headers) != len(
392405
mcp_server.authorization_headers
393406
):
407+
# If OAuth was required and no headers passed, probe endpoint and forward
408+
# 401 with WWW-Authenticate so the client can perform OAuth
409+
uses_oauth = (
410+
constants.MCP_AUTH_OAUTH
411+
in mcp_server.resolved_authorization_headers.values()
412+
)
413+
if uses_oauth and (
414+
mcp_headers is None or not mcp_headers.get(mcp_server.name)
415+
):
416+
await probe_mcp_oauth_and_raise_401(mcp_server.url)
394417
logger.warning(
395418
"Skipping MCP server %s: required %d auth headers but only resolved %d",
396419
mcp_server.name,

tests/integration/endpoints/test_rlsapi_v1_integration.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,7 @@ async def test_rlsapi_v1_infer_no_mcp_servers_passes_empty_tools(
389389

390390
mocker.patch(
391391
"app.endpoints.rlsapi_v1.get_mcp_tools",
392+
new_callable=mocker.AsyncMock,
392393
return_value=[],
393394
)
394395

@@ -437,6 +438,7 @@ async def test_rlsapi_v1_infer_mcp_tools_passed_to_llm(
437438
]
438439
mocker.patch(
439440
"app.endpoints.rlsapi_v1.get_mcp_tools",
441+
new_callable=mocker.AsyncMock,
440442
return_value=mcp_tools,
441443
)
442444

0 commit comments

Comments
 (0)