4949 MessageHandlerFactory ,
5050)
5151from mcpgateway .utils .internal_http import (
52+ internal_loopback_base_url ,
5253 post_rpc_in_process ,
5354)
5455
@@ -1021,27 +1022,32 @@ async def _execute_forwarded_request(self, request: Dict[str, Any]) -> Dict[str,
10211022 async def _execute_forwarded_http_request (self , request : Dict [str , Any ], redis : Any ) -> None :
10221023 """Execute a forwarded Streamable HTTP request in-process and reply via Redis.
10231024
1024- A forwarded request always lands here on the worker that OWNS the
1025- downstream session (its ``WORKER_ID`` matched the Redis owner key), so the
1026- bound upstream session in this process's ``UpstreamSessionRegistry`` can
1027- serve it. We therefore dispatch the JSON-RPC call to the local ``/rpc``
1028- route via an **in-process ASGI transport** instead of a network loopback
1029- to ``127.0.0.1``: a real loopback hits the shared gunicorn socket and the
1030- kernel routes it to an arbitrary worker that does not hold this session,
1031- which breaks upstream-session reuse and fails the request. The
1032- ``x-forwarded-internally`` header stops the re-entered handler from
1033- forwarding again.
1025+ The request lands on the worker that owns the downstream session, so the
1026+ bound upstream session in this process can serve it. It is dispatched
1027+ in-process to the trusted-internal ``/_internal/mcp/rpc`` endpoint rather
1028+ than the public ``/rpc``.
1029+
1030+ Public ``/rpc`` only understands ContextForge JWTs and cookies, so an
1031+ OAuth bearer or an ``MCP_REQUIRE_AUTH=false`` public-only request would
1032+ 401 if re-authenticated there. Instead, the originating worker's
1033+ already-validated identity rides in ``auth_context`` (the encoded
1034+ ``x-contextforge-auth-context`` header); with the
1035+ ``x-contextforge-mcp-runtime: affinity`` marker, the shared-secret HMAC,
1036+ and the loopback client address, the trusted endpoint accepts the request
1037+ and reconstructs the same user the edge established.
10341038
10351039 Args:
10361040 request: Serialized HTTP request data from Redis Pub/Sub containing:
10371041 - type: "http_forward"
10381042 - response_channel: Redis channel to publish response to
10391043 - mcp_session_id: Session identifier
1040- - method: HTTP method (GET, POST, DELETE)
1041- - path: Request path (e.g., /servers/{id}/mcp)
1042- - headers: Request headers dict (lowercased keys)
1043- - body: Hex-encoded request body
1044- redis: Redis client for publishing response
1044+ - method: HTTP method (POST primarily)
1045+ - path: Original request path (e.g., /servers/{id}/mcp)
1046+ - headers: Original request headers (lowercased keys)
1047+ - body: Hex-encoded JSON-RPC request body
1048+ - auth_context: base64url-encoded auth context from the
1049+ originating worker's ``streamable_http_auth()`` result
1050+ redis: Redis client for publishing the response
10451051 """
10461052 response_channel = request .get ("response_channel" )
10471053
@@ -1057,6 +1063,7 @@ async def _publish(status: int, body: bytes, headers: Optional[Dict[str, str]] =
10571063 headers = request .get ("headers" , {})
10581064 body_hex = request .get ("body" , "" )
10591065 mcp_session_id = request .get ("mcp_session_id" )
1066+ auth_context_header = request .get ("auth_context" ) or ""
10601067 body = bytes .fromhex (body_hex ) if body_hex else b""
10611068
10621069 session_short = mcp_session_id [:8 ] if mcp_session_id and len (mcp_session_id ) >= 8 else "unknown"
@@ -1079,7 +1086,8 @@ async def _publish(status: int, body: bytes, headers: Optional[Dict[str, str]] =
10791086 await _publish (202 , b"" )
10801087 return
10811088
1082- # Inject the virtual server id (from the path) into params so /rpc routes correctly.
1089+ # Inject the virtual server id (from the path) into params so the
1090+ # dispatcher routes to the right virtual server.
10831091 server_match = _SERVER_ID_RE .search (path )
10841092 if server_match and isinstance (json_body , dict ):
10851093 if not isinstance (json_body .get ("params" ), dict ):
@@ -1088,31 +1096,53 @@ async def _publish(status: int, body: bytes, headers: Optional[Dict[str, str]] =
10881096 body = orjson .dumps (json_body )
10891097
10901098 # First-Party - lazy imports avoid a circular dependency with main/transport.
1091- # First-Party
1099+ from mcpgateway .auth_context import _expected_internal_mcp_runtime_auth_header , encode_internal_mcp_auth_context # pylint: disable=import-outside-toplevel,protected-access
1100+ from mcpgateway .main import app # pylint: disable=import-outside-toplevel
10921101 from mcpgateway .utils .passthrough_headers import safe_extract_and_filter_for_loopback # pylint: disable=import-outside-toplevel
1093- from mcpgateway .utils .verify_credentials import _resolve_auth_header_name # pylint: disable=import-outside-toplevel
1094-
1102+ from mcpgateway .utils .verify_credentials import _resolve_auth_header_name # pylint: disable=import-outside-toplevel,protected-access
1103+
1104+ # A forwarded payload may omit the auth context; fall back to an
1105+ # encoded public-only ({}) context so the endpoint applies default
1106+ # visibility instead of rejecting the dispatch with 400.
1107+ if not auth_context_header :
1108+ auth_context_header = encode_internal_mcp_auth_context ({})
1109+
1110+ # Trust headers for the internal /_internal/mcp/rpc endpoint:
1111+ # - x-contextforge-mcp-runtime: "affinity" caller marker
1112+ # - x-contextforge-mcp-runtime-auth: shared-secret HMAC
1113+ # - x-contextforge-auth-context: the encoded edge auth context, so the
1114+ # endpoint reconstructs the same user without re-authenticating.
10951115 rpc_headers = {
10961116 "content-type" : "application/json" ,
10971117 "x-mcp-session-id" : mcp_session_id or "" ,
1098- "x-forwarded-internally" : "true" ,
1118+ "x-contextforge-mcp-runtime" : "affinity" ,
1119+ "x-contextforge-mcp-runtime-auth" : _expected_internal_mcp_runtime_auth_header (),
1120+ "x-contextforge-auth-context" : auth_context_header ,
10991121 }
1100- auth_header = _resolve_auth_header_name (settings ).lower ()
1101- if auth_header in headers :
1102- rpc_headers [auth_header ] = headers [auth_header ]
1122+ # Preserve the bearer under the configured auth header (AUTH_HEADER_NAME),
1123+ # not a hardcoded "authorization": the CSRF bearer short-circuit keys on
1124+ # the configured header, so a custom header would otherwise be dropped.
1125+ # This is defense-in-depth; the endpoint trusts the auth-context above.
1126+ auth_header_name = _resolve_auth_header_name (settings ).lower ()
1127+ original_auth = headers .get (auth_header_name ) or headers .get (_resolve_auth_header_name (settings ))
1128+ if original_auth :
1129+ rpc_headers [auth_header_name ] = original_auth
11031130 # Preserve passthrough headers destined for upstream MCP servers (#3640).
11041131 rpc_headers .update (safe_extract_and_filter_for_loopback (headers ))
11051132
1106- # Dispatch IN-PROCESS so /rpc resolves the bound upstream session from
1107- # this worker's registry instead of bouncing back through the shared
1108- # socket to a random worker (see post_rpc_in_process).
1109- response = await post_rpc_in_process (
1110- content = body ,
1111- headers = rpc_headers ,
1112- timeout = settings .mcpgateway_pool_rpc_forward_timeout ,
1113- )
1133+ # Dispatch IN-PROCESS to the trusted internal endpoint. The explicit
1134+ # client=("127.0.0.1", 0) tells ASGITransport to set scope["client"]
1135+ # to a loopback address so the trust check accepts the request.
1136+ transport = httpx .ASGITransport (app = app , client = ("127.0.0.1" , 0 ))
1137+ async with httpx .AsyncClient (transport = transport , base_url = internal_loopback_base_url ()) as client :
1138+ response = await client .post (
1139+ "/_internal/mcp/rpc" ,
1140+ content = body ,
1141+ headers = rpc_headers ,
1142+ timeout = settings .mcpgateway_pool_rpc_forward_timeout ,
1143+ )
11141144
1115- logger .debug (f"[HTTP_AFFINITY] Worker { WORKER_ID } | Session { session_short } ... | Executed in-process: { response .status_code } " )
1145+ logger .debug (f"[HTTP_AFFINITY] Worker { WORKER_ID } | Session { session_short } ... | Executed in-process via /_internal/mcp/rpc : { response .status_code } " )
11161146
11171147 resp_headers = {"content-type" : "application/json" }
11181148 if mcp_session_id :
@@ -1149,6 +1179,7 @@ async def forward_to_owner(
11491179 headers : Dict [str , str ],
11501180 body : bytes ,
11511181 query_string : str = "" ,
1182+ auth_context : Optional [str ] = None ,
11521183 ) -> Optional [Dict [str , Any ]]:
11531184 """Forward a Streamable HTTP request to the worker that owns the session via Redis Pub/Sub.
11541185
@@ -1165,6 +1196,10 @@ async def forward_to_owner(
11651196 headers: Request headers.
11661197 body: Request body bytes.
11671198 query_string: Query string if any.
1199+ auth_context: Encoded ``x-contextforge-auth-context`` value carrying the
1200+ originating worker's already-validated identity, so the owner can
1201+ dispatch to the trusted internal endpoint without re-authenticating
1202+ (OAuth bearers and ``MCP_REQUIRE_AUTH=false`` public-only survive).
11681203
11691204 Returns:
11701205 Dict with 'status', 'headers', and 'body' from the owner worker's response,
@@ -1206,6 +1241,8 @@ async def forward_to_owner(
12061241 "body" : body .hex () if body else "" , # Hex encode binary body
12071242 "original_worker" : WORKER_ID ,
12081243 "timestamp" : time .time (),
1244+ # Encoded edge identity; lets the owner dispatch without re-authenticating.
1245+ "auth_context" : auth_context ,
12091246 }
12101247
12111248 # Subscribe to response channel BEFORE publishing request (prevent race)
0 commit comments