Skip to content

Commit 68b5341

Browse files
author
Olivier Gintrand
committed
fix(oauth): include resource_metadata in WWW-Authenticate for strict auth
Signed-off-by: Olivier Gintrand <olivier.gintrand@forterro.com>
1 parent a02a04b commit 68b5341

File tree

1 file changed

+44
-47
lines changed

1 file changed

+44
-47
lines changed

mcpgateway/transports/streamablehttp_transport.py

Lines changed: 44 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,6 @@
6464
# First-Party
6565
from mcpgateway.cache.global_config_cache import global_config_cache
6666
from mcpgateway.common.models import LogLevel
67-
from mcpgateway.common.validators import validate_meta_data as _validate_meta_data
6867
from mcpgateway.config import settings
6968
from mcpgateway.db import SessionLocal
7069
from mcpgateway.middleware.rbac import _ACCESS_DENIED_MSG
@@ -1158,22 +1157,6 @@ async def _validate_streamable_session_access(
11581157
return False, HTTP_403_FORBIDDEN, "Session owner metadata unavailable"
11591158

11601159

1161-
def _build_paginated_params(meta: Optional[Any]) -> Optional[PaginatedRequestParams]:
1162-
"""Build a ``PaginatedRequestParams`` carrying ``_meta`` when provided.
1163-
1164-
Args:
1165-
meta: Request metadata (_meta) from the original MCP request, or ``None``.
1166-
1167-
Returns:
1168-
A ``PaginatedRequestParams`` instance with ``_meta`` set, or ``None`` when *meta* is falsy.
1169-
"""
1170-
if not meta:
1171-
return None
1172-
# CWE-532: log only key names, never values which may carry PII/tokens
1173-
logger.debug("Forwarding _meta to remote gateway (keys: %s)", sorted(meta.keys()) if isinstance(meta, dict) else type(meta).__name__)
1174-
return PaginatedRequestParams(_meta=meta)
1175-
1176-
11771160
async def _proxy_list_tools_to_gateway(gateway: Any, request_headers: dict, user_context: dict, meta: Optional[Any] = None) -> List[types.Tool]: # pylint: disable=unused-argument
11781161
"""Proxy tools/list request directly to remote MCP gateway using MCP SDK.
11791162
@@ -1211,8 +1194,14 @@ async def _proxy_list_tools_to_gateway(gateway: Any, request_headers: dict, user
12111194
async with ClientSession(read_stream, write_stream) as session:
12121195
await session.initialize()
12131196

1197+
# Prepare params with _meta if provided
1198+
params = None
1199+
if meta:
1200+
params = PaginatedRequestParams(_meta=meta)
1201+
logger.debug("Forwarding _meta to remote gateway: %s", meta)
1202+
12141203
# List tools with _meta forwarded
1215-
result = await session.list_tools(params=_build_paginated_params(meta))
1204+
result = await session.list_tools(params=params)
12161205
return result.tools
12171206

12181207
except Exception as e:
@@ -1254,16 +1243,21 @@ async def _proxy_list_resources_to_gateway(gateway: Any, request_headers: dict,
12541243

12551244
logger.info("Proxying resources/list to gateway %s at %s", gateway.id, gateway.url)
12561245
if meta:
1257-
# CWE-532: log only key names, never values which may carry PII/tokens
1258-
logger.debug("Forwarding _meta to remote gateway (keys: %s)", sorted(meta.keys()) if isinstance(meta, dict) else type(meta).__name__)
1246+
logger.debug("Forwarding _meta to remote gateway: %s", meta)
12591247

12601248
# Use MCP SDK to connect and list resources
12611249
async with streamablehttp_client(url=gateway.url, headers=headers, timeout=settings.mcpgateway_direct_proxy_timeout) as (read_stream, write_stream, _get_session_id):
12621250
async with ClientSession(read_stream, write_stream) as session:
12631251
await session.initialize()
12641252

1253+
# Prepare params with _meta if provided
1254+
params = None
1255+
if meta:
1256+
params = PaginatedRequestParams(_meta=meta)
1257+
logger.debug("Forwarding _meta to remote gateway: %s", meta)
1258+
12651259
# List resources with _meta forwarded
1266-
result = await session.list_resources(params=_build_paginated_params(meta))
1260+
result = await session.list_resources(params=params)
12671261

12681262
logger.info("Received %s resources from gateway %s", len(result.resources), gateway.id)
12691263
return result.resources
@@ -1315,8 +1309,7 @@ async def _proxy_read_resource_to_gateway(gateway: Any, resource_uri: str, user_
13151309

13161310
logger.info("Proxying resources/read for %s to gateway %s at %s", resource_uri, gateway.id, gateway.url)
13171311
if meta:
1318-
# CWE-532: log only key names, never values which may carry PII/tokens
1319-
logger.debug("Forwarding _meta to remote gateway (keys: %s)", sorted(meta.keys()) if isinstance(meta, dict) else type(meta).__name__)
1312+
logger.debug("Forwarding _meta to remote gateway: %s", meta)
13201313

13211314
# Use MCP SDK to connect and read resource
13221315
async with streamablehttp_client(url=gateway.url, headers=headers, timeout=settings.mcpgateway_direct_proxy_timeout) as (read_stream, write_stream, _get_session_id):
@@ -1326,10 +1319,8 @@ async def _proxy_read_resource_to_gateway(gateway: Any, resource_uri: str, user_
13261319
# Prepare request params with _meta if provided
13271320
if meta:
13281321
# Create params and inject _meta
1329-
# by_alias=True ensures the alias "_meta" key is written so
1330-
# model_validate resolves it correctly (fixes CWE-20 silent drop)
13311322
request_params = ReadResourceRequestParams(uri=resource_uri)
1332-
request_params_dict = request_params.model_dump(by_alias=True)
1323+
request_params_dict = request_params.model_dump()
13331324
request_params_dict["_meta"] = meta
13341325

13351326
# Send request with _meta
@@ -2353,20 +2344,23 @@ async def read_resource(resource_uri: str) -> Union[str, bytes]:
23532344
return ""
23542345

23552346
# Direct proxy mode: forward request to remote MCP server
2356-
# SECURITY: CWE-532 protection - Log only meta_data key names, NEVER values
2357-
# Metadata may contain PII, authentication tokens, or sensitive context that
2358-
# MUST NOT be written to logs. This is a critical security control.
2359-
logger.debug(
2360-
"Using direct_proxy mode for resources/read %s, server %s, gateway %s (from %s header), forwarding _meta keys: %s",
2361-
resource_uri,
2362-
server_id,
2363-
gateway.id,
2364-
GATEWAY_ID_HEADER,
2365-
sorted(meta_data.keys()) if meta_data else None,
2366-
)
2367-
# CWE-400: validate _meta limits before network I/O (bypassed in direct-proxy branch)
2368-
_validate_meta_data(meta_data)
2369-
contents = await _proxy_read_resource_to_gateway(gateway, str(resource_uri), user_context, meta_data)
2347+
# Get _meta from request context if available
2348+
meta = None
2349+
try:
2350+
request_ctx = mcp_app.request_context
2351+
meta = request_ctx.meta
2352+
logger.info(
2353+
"Using direct_proxy mode for resources/read %s, server %s, gateway %s (from %s header), forwarding _meta: %s",
2354+
resource_uri,
2355+
server_id,
2356+
gateway.id,
2357+
GATEWAY_ID_HEADER,
2358+
meta,
2359+
)
2360+
except (LookupError, AttributeError) as e:
2361+
logger.debug("No request context available for _meta extraction: %s", e)
2362+
2363+
contents = await _proxy_read_resource_to_gateway(gateway, str(resource_uri), user_context, meta)
23702364
if contents:
23712365
# Return first content (text or blob)
23722366
first_content = contents[0]
@@ -3487,15 +3481,14 @@ async def _auth_no_token(self, *, path: str, bearer_header_supplied: bool) -> bo
34873481
Returns:
34883482
True if the request is allowed with public-only access, False if rejected.
34893483
"""
3490-
# If client supplied a Bearer header but with empty credentials, fail closed
3491-
if bearer_header_supplied:
3492-
return await self._send_error(detail="Invalid authentication credentials", headers={"WWW-Authenticate": "Bearer"})
3493-
3484+
# Build the WWW-Authenticate header, enriching it with RFC 9728
3485+
# resource_metadata when the target server has OAuth enabled.
34943486
# Per-server OAuth enforcement MUST run before the global auth check so that
34953487
# oauth_enabled servers always return 401 with resource_metadata URL (RFC 9728).
34963488
# Without this, strict mode (mcp_require_auth=True) returns a generic
34973489
# WWW-Authenticate: Bearer with no resource_metadata, and MCP clients cannot
3498-
# discover the OAuth server to authenticate. (Fixes #3752)
3490+
# discover the OAuth server to authenticate.
3491+
www_auth = "Bearer"
34993492
match = _SERVER_ID_RE.search(path)
35003493
if match:
35013494
per_server_id = match.group("server_id")
@@ -3509,9 +3502,13 @@ async def _auth_no_token(self, *, path: str, bearer_header_supplied: bool) -> bo
35093502
logger.exception("OAuth enforcement check failed for server %s", per_server_id)
35103503
return await self._send_error(detail="Service unavailable — unable to verify server authentication requirements", status_code=503)
35113504

3512-
# Strict mode: require authentication (non-OAuth servers get generic 401)
3505+
# If client supplied a Bearer header but with empty credentials, fail closed
3506+
if bearer_header_supplied:
3507+
return await self._send_error(detail="Invalid authentication credentials", headers={"WWW-Authenticate": www_auth})
3508+
3509+
# Strict mode: require authentication
35133510
if settings.mcp_require_auth:
3514-
return await self._send_error(detail="Authentication required for MCP endpoints", headers={"WWW-Authenticate": "Bearer"})
3511+
return await self._send_error(detail="Authentication required for MCP endpoints", headers={"WWW-Authenticate": www_auth})
35153512

35163513
# Permissive mode: allow unauthenticated access with public-only scope
35173514
# Set context indicating unauthenticated user with public-only access (teams=[])

0 commit comments

Comments
 (0)