Skip to content

Commit f74e717

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 f74e717

File tree

3 files changed

+56
-54
lines changed

3 files changed

+56
-54
lines changed

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2136,6 +2136,11 @@ OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
21362136
# TOOL_CONCURRENT_LIMIT=10
21372137
# GATEWAY_TOOL_NAME_SEPARATOR=-
21382138

2139+
# Maximum length of response text returned for non-JSON REST API responses
2140+
# Longer responses are truncated to prevent exposing excessive sensitive data
2141+
# Default: 5000 characters, Range: 1000-100000
2142+
# REST_RESPONSE_TEXT_MAX_LENGTH=5000
2143+
21392144
# Prompt Configuration
21402145
# PROMPT_CACHE_SIZE=100
21412146
# MAX_PROMPT_SIZE=102400

.secrets.baseline

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"files": "^.secrets.baseline|package-lock.json|Cargo.lock|scripts/sign_image.sh|scripts/zap|sonar-project.properties|uv.lock|go.sum|mcpgateway/sri_hashes.json|^.secrets.baseline$",
44
"lines": null
55
},
6-
"generated_at": "2026-04-14T13:09:46Z",
6+
"generated_at": "2026-04-14T14:08:10Z",
77
"plugins_used": [
88
{
99
"name": "AWSKeyDetector"
@@ -4830,7 +4830,7 @@
48304830
"hashed_secret": "ff37a98a9963d347e9749a5c1b3936a4a245a6ff",
48314831
"is_secret": false,
48324832
"is_verified": false,
4833-
"line_number": 2228,
4833+
"line_number": 2236,
48344834
"type": "Secret Keyword",
48354835
"verified_result": null
48364836
}
@@ -8624,39 +8624,39 @@
86248624
"hashed_secret": "ee977806d7286510da8b9a7492ba58e2484c0ecc",
86258625
"is_secret": false,
86268626
"is_verified": false,
8627-
"line_number": 6376,
8627+
"line_number": 6907,
86288628
"type": "Secret Keyword",
86298629
"verified_result": null
86308630
},
86318631
{
86328632
"hashed_secret": "f2e7745f43b0ef0e2c2faf61d6c6a28be2965750",
86338633
"is_secret": false,
86348634
"is_verified": false,
8635-
"line_number": 6868,
8635+
"line_number": 7399,
86368636
"type": "Secret Keyword",
86378637
"verified_result": null
86388638
},
86398639
{
86408640
"hashed_secret": "4a249743d4d2241bd2ae085b4fe654d089488295",
86418641
"is_secret": false,
86428642
"is_verified": false,
8643-
"line_number": 8215,
8643+
"line_number": 8746,
86448644
"type": "Secret Keyword",
86458645
"verified_result": null
86468646
},
86478647
{
86488648
"hashed_secret": "0c8d051d3c7eada5d31b53d9936fce6bcc232ae2",
86498649
"is_secret": false,
86508650
"is_verified": false,
8651-
"line_number": 8357,
8651+
"line_number": 8888,
86528652
"type": "Secret Keyword",
86538653
"verified_result": null
86548654
},
86558655
{
86568656
"hashed_secret": "f2b14f68eb995facb3a1c35287b778d5bd785511",
86578657
"is_secret": false,
86588658
"is_verified": false,
8659-
"line_number": 8733,
8659+
"line_number": 9264,
86608660
"type": "Secret Keyword",
86618661
"verified_result": null
86628662
}

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)