Skip to content
Draft
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
42 changes: 41 additions & 1 deletion src/app/routes/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,52 @@
logger = get_logger(__name__)


class _ForwardJWTToMCPMiddleware:
"""ASGI shim: copy the inbound ``Authorization`` JWT into a non-excluded
header so it survives FastMCP's proxy to the underlying /v1 handler.

FastMCP's ``get_http_headers()`` strips ``authorization`` from the headers
it forwards when an MCP tool re-invokes a /v1 route, so a gateway-forwarded
JWT would otherwise be lost (the client then falls back to lakehouse creds,
which OpenSearch rejects with 401 and which carry no RBAC roles -> 403).
We *copy* (not move) the value into ``OPENRAG_MCP_JWT_HEADER`` (default
``X-OpenRAG-JWT``), which is not in FastMCP's exclude set; the original
header is left intact. ``get_api_key_user_async`` reads it as a fallback.

Scoped to the /mcp sub-app only, so normal /v1 and UI traffic is untouched.
"""

def __init__(self, app):
self.app = app

async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
from config.settings import get_mcp_forwarded_jwt_header

target = get_mcp_forwarded_jwt_header().lower().encode("latin-1")
headers = scope.get("headers", [])
auth = None
has_target = False
for name, value in headers:
lname = name.lower()
if lname == b"authorization":
auth = value
elif lname == target:
has_target = True
if auth is not None and not has_target:
scope = dict(scope)
scope["headers"] = list(headers) + [(target, auth)]
Comment on lines +44 to +52

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Overwrite client-supplied forwarded JWT headers.

has_target lets an inbound X-OpenRAG-JWT value win over the authenticated Authorization value. After FastMCP strips Authorization, /v1 can consume that client-controlled fallback header as identity/roles. Strip any inbound target header, then append only the value copied from Authorization.

🛡️ Proposed fix
-        headers = scope.get("headers", [])
+        headers = list(scope.get("headers", []))
         auth = None
-        has_target = False
+        forwarded_headers = []
         for name, value in headers:
             lname = name.lower()
             if lname == b"authorization":
                 auth = value
-            elif lname == target:
-                has_target = True
-        if auth is not None and not has_target:
+                forwarded_headers.append((name, value))
+            elif lname != target:
+                forwarded_headers.append((name, value))
+        if auth and auth.strip():
             scope = dict(scope)
-            scope["headers"] = list(headers) + [(target, auth)]
+            scope["headers"] = forwarded_headers + [(target, auth)]
+        elif len(forwarded_headers) != len(headers):
+            scope = dict(scope)
+            scope["headers"] = forwarded_headers
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/routes/mcp.py` around lines 44 - 52, The code currently only appends
the Authorization header as the target when the target header is not present
(when has_target is false), which allows client-supplied target headers to take
precedence. Instead, you need to strip any inbound target header from the
headers list first, then unconditionally append the Authorization value as the
target header when auth is present. Modify the logic to filter the headers list
to remove any headers where the lowercased name equals the target value
(removing client-supplied target headers), then if auth is not none, append it
as the target header in the modified scope headers list.

await self.app(scope, receive, send)


def mount_mcp(app: FastAPI):
"""Mount the FastMCP app at /mcp and return its lifespan context manager."""
logger.info("Creating MCP server")
mcp_server = create_mcp_server(app)
mcp_http_app = mcp_server.http_app(transport="streamable-http", path="/")
app.mount("/mcp", mcp_http_app)
app.mount("/mcp", _ForwardJWTToMCPMiddleware(mcp_http_app))
logger.info("MCP server mounted at /mcp (streamable-http)")

# FastMCP requires its own lifespan to be run so that the
Expand Down
12 changes: 12 additions & 0 deletions src/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,18 @@ def get_jwt_auth_header() -> str:
return os.getenv("OPENRAG_JWT_AUTH_HEADER", "Authorization")


def get_mcp_forwarded_jwt_header() -> str:
"""Header into which the /mcp ASGI shim copies the inbound JWT.

FastMCP's get_http_headers() strips 'authorization' from the headers it
forwards to the underlying /v1 handler when an MCP tool is invoked, so a
JWT arriving on /mcp in the Authorization header never reaches the /v1
auth dependency. The shim copies it into this (non-excluded) header so it
survives the proxy; get_api_key_user_async reads it as a fallback.
Read per-call so tests can override via monkeypatch.setenv."""
return os.getenv("OPENRAG_MCP_JWT_HEADER", "X-OpenRAG-JWT")


def get_jwt_issuer_verify_tls() -> bool:
"""Whether to verify TLS when fetching JWT signing keys from the token's
``iss`` URL (``verify_jwt_from_issuer``). Defaults to false for internal
Expand Down
13 changes: 11 additions & 2 deletions src/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -844,10 +844,19 @@ async def get_api_key_user_async(
# the user's roles (synced via request.state.jwt_roles ->
# _attach_db_user_id), with a 401 when no recognized role is present.
from auth.jwt_roles import jwt_roles_enabled
from config.settings import IBM_AUTH_ENABLED, get_jwt_auth_header
from config.settings import (
IBM_AUTH_ENABLED,
get_jwt_auth_header,
get_mcp_forwarded_jwt_header,
)
from config.utils import resolve_jwt_claims

raw_jwt = request.headers.get(get_jwt_auth_header(), "")
# Primary: the gateway-forwarded JWT header (default Authorization). Fallback:
# the header the /mcp shim copies the JWT into, because FastMCP strips
# Authorization before proxying an MCP tool call to this /v1 handler.
raw_jwt = request.headers.get(get_jwt_auth_header(), "") or request.headers.get(
get_mcp_forwarded_jwt_header(), ""
)
Comment on lines +854 to +859

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Treat blank primary JWT headers as absent before falling back.

A whitespace-only primary header is truthy for or, so a valid MCP forwarded JWT is ignored and the request falls through as unauthenticated.

🔧 Proposed fix
-    raw_jwt = request.headers.get(get_jwt_auth_header(), "") or request.headers.get(
-        get_mcp_forwarded_jwt_header(), ""
-    )
+    primary_jwt = request.headers.get(get_jwt_auth_header(), "").strip()
+    raw_jwt = primary_jwt or request.headers.get(get_mcp_forwarded_jwt_header(), "").strip()
📝 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
# Primary: the gateway-forwarded JWT header (default Authorization). Fallback:
# the header the /mcp shim copies the JWT into, because FastMCP strips
# Authorization before proxying an MCP tool call to this /v1 handler.
raw_jwt = request.headers.get(get_jwt_auth_header(), "") or request.headers.get(
get_mcp_forwarded_jwt_header(), ""
)
# Primary: the gateway-forwarded JWT header (default Authorization). Fallback:
# the header the /mcp shim copies the JWT into, because FastMCP strips
# Authorization before proxying an MCP tool call to this /v1 handler.
primary_jwt = request.headers.get(get_jwt_auth_header(), "").strip()
raw_jwt = primary_jwt or request.headers.get(get_mcp_forwarded_jwt_header(), "").strip()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/dependencies.py` around lines 854 - 859, The JWT header retrieval logic
in the raw_jwt assignment uses `or` to fallback from the primary JWT auth header
to the MCP forwarded JWT header. However, if the primary header (from
get_jwt_auth_header()) contains only whitespace, it evaluates as truthy and
prevents the fallback, causing valid MCP forwarded JWTs to be ignored. Fix this
by stripping whitespace from the primary header before using the `or` operator,
so that whitespace-only values are treated as absent and the fallback to
get_mcp_forwarded_jwt_header() is properly triggered.

logger.debug(
"[AUTH] API-key path JWT header lookup",
header_name=get_jwt_auth_header(),
Expand Down
Loading