3939 InternalTrustedMCPTransportBridge,
4040 MCPRuntimeHeaderTransportWrapper,
4141 MCPPathRewriteMiddleware,
42+ _expected_internal_mcp_runtime_auth_header,
4243 _build_internal_mcp_auth_scope,
4344 _build_internal_mcp_forwarded_user,
4445 _decode_internal_mcp_auth_context,
@@ -133,6 +134,17 @@ def _make_request(
133134 return request
134135
135136
137+ def _trusted_internal_mcp_headers(auth_context: dict[str, object], **extra_headers: str) -> dict[str, str]:
138+ """Build trusted Rust->Python internal MCP headers for unit tests."""
139+ headers = {
140+ "x-contextforge-mcp-runtime": "rust",
141+ "x-contextforge-mcp-runtime-auth": _expected_internal_mcp_runtime_auth_header(),
142+ "x-contextforge-auth-context": base64.urlsafe_b64encode(orjson.dumps(auth_context)).decode().rstrip("="),
143+ }
144+ headers.update(extra_headers)
145+ return headers
146+
147+
136148def _import_fresh_main_module(
137149 monkeypatch: pytest.MonkeyPatch,
138150 *,
@@ -382,6 +394,7 @@ async def handle_streamable_http(self, scope, receive, send):
382394 "query_string": b"session_id=abc123",
383395 "headers": [
384396 (b"x-contextforge-mcp-runtime", b"rust"),
397+ (b"x-contextforge-mcp-runtime-auth", _expected_internal_mcp_runtime_auth_header().encode("ascii")),
385398 (b"x-contextforge-auth-context", encoded_auth.encode("ascii")),
386399 (b"x-contextforge-server-id", b"server-1"),
387400 ],
@@ -443,6 +456,7 @@ async def handle_streamable_http(self, _scope, _receive, send):
443456 "query_string": b"session_id=abc123",
444457 "headers": [
445458 (b"x-contextforge-mcp-runtime", b"rust"),
459+ (b"x-contextforge-mcp-runtime-auth", _expected_internal_mcp_runtime_auth_header().encode("ascii")),
446460 (b"x-contextforge-auth-context", encoded_auth.encode("ascii")),
447461 (b"x-contextforge-session-validated", b"rust"),
448462 ],
@@ -503,6 +517,7 @@ async def handle_streamable_http(self, scope, receive, send):
503517 "query_string": b"",
504518 "headers": [
505519 (b"x-contextforge-mcp-runtime", b"rust"),
520+ (b"x-contextforge-mcp-runtime-auth", _expected_internal_mcp_runtime_auth_header().encode("ascii")),
506521 (b"x-contextforge-auth-context", encoded_auth.encode("ascii")),
507522 (b"x-contextforge-server-id", b"server-1"),
508523 ],
@@ -532,7 +547,10 @@ async def test_bridge_rejects_missing_internal_auth_context(self):
532547 "method": "GET",
533548 "path": "/_internal/mcp/transport",
534549 "query_string": b"",
535- "headers": [(b"x-contextforge-mcp-runtime", b"rust")],
550+ "headers": [
551+ (b"x-contextforge-mcp-runtime", b"rust"),
552+ (b"x-contextforge-mcp-runtime-auth", _expected_internal_mcp_runtime_auth_header().encode("ascii")),
553+ ],
536554 "client": ("127.0.0.1", 5000),
537555 }
538556
@@ -864,6 +882,7 @@ def test_build_internal_mcp_forwarded_user_rejects_invalid_auth_context(self):
864882 request = MagicMock(spec=Request)
865883 request.headers = {
866884 "x-contextforge-mcp-runtime": "rust",
885+ "x-contextforge-mcp-runtime-auth": _expected_internal_mcp_runtime_auth_header(),
867886 "x-contextforge-auth-context": "not-base64",
868887 }
869888 request.client = SimpleNamespace(host="127.0.0.1")
@@ -875,28 +894,37 @@ def test_build_internal_mcp_forwarded_user_rejects_invalid_auth_context(self):
875894 assert excinfo.value.status_code == 400
876895 assert "Invalid trusted MCP auth context" in excinfo.value.detail
877896
878- def test_build_internal_mcp_forwarded_user_sets_session_validated_and_token_teams (self):
879- """Trusted forwarded auth should copy teams and set the Rust session validation marker ."""
897+ def test_build_internal_mcp_forwarded_user_requires_internal_runtime_auth_header (self):
898+ """Trusted Rust forwarding must include the shared internal-auth header ."""
880899 request = MagicMock(spec=Request)
881900 request.headers = {
882901 "x-contextforge-mcp-runtime": "rust",
883- "x-contextforge-session-validated": "rust",
884- "x-contextforge-auth-context": base64.urlsafe_b64encode(
885- orjson.dumps(
886- {
887- "email": "user@example.com",
888- "teams": ["team-a"],
889- "is_authenticated": True,
890- "is_admin": False,
891- "permission_is_admin": True,
892- "token_use": "session",
893- }
894- )
895- )
896- .decode()
897- .rstrip("="),
902+ "x-contextforge-auth-context": base64.urlsafe_b64encode(orjson.dumps({"email": "user@example.com"})).decode().rstrip("="),
898903 }
899904 request.client = SimpleNamespace(host="127.0.0.1")
905+ request.state = MagicMock()
906+
907+ with pytest.raises(HTTPException) as excinfo:
908+ _build_internal_mcp_forwarded_user(request)
909+
910+ assert excinfo.value.status_code == 403
911+ assert "only available to the local Rust runtime" in excinfo.value.detail
912+
913+ def test_build_internal_mcp_forwarded_user_sets_session_validated_and_token_teams(self):
914+ """Trusted forwarded auth should copy teams and set the Rust session validation marker."""
915+ request = MagicMock(spec=Request)
916+ request.headers = _trusted_internal_mcp_headers(
917+ {
918+ "email": "user@example.com",
919+ "teams": ["team-a"],
920+ "is_authenticated": True,
921+ "is_admin": False,
922+ "permission_is_admin": True,
923+ "token_use": "session",
924+ },
925+ **{"x-contextforge-session-validated": "rust"},
926+ )
927+ request.client = SimpleNamespace(host="127.0.0.1")
900928 request.state = SimpleNamespace()
901929
902930 forwarded = _build_internal_mcp_forwarded_user(request)
@@ -4814,6 +4842,19 @@ async def test_list_and_get_a2a_agents_branches(self, monkeypatch):
48144842class TestRpcHandling:
48154843 """Cover RPC handler branches."""
48164844
4845+ @pytest.fixture(autouse=True)
4846+ def _trust_internal_rust_headers_for_handler_logic_tests(self, monkeypatch):
4847+ """Keep this suite focused on handler logic, not trust-boundary validation.
4848+
4849+ The trust boundary itself is covered separately by the dedicated helper
4850+ and middleware tests above.
4851+ """
4852+ monkeypatch.setattr(
4853+ "mcpgateway.main._is_trusted_internal_mcp_runtime_request",
4854+ lambda request: request.headers.get("x-contextforge-mcp-runtime") == "rust"
4855+ and getattr(getattr(request, "client", None), "host", None) in ("127.0.0.1", "::1"),
4856+ )
4857+
48174858 @staticmethod
48184859 def _make_request(payload: dict) -> MagicMock:
48194860 request = MagicMock(spec=Request)
@@ -10784,18 +10825,18 @@ async def test_resources_subscribe_denied_with_servers_use_only(self):
1078410825 assert result["error"]["code"] == -32003
1078510826 assert "Access denied" in result["error"]["message"]
1078610827
10787- async def test_logging_set_level_allowed_with_servers_use (self):
10788- """Token scoped to servers.use should be allowed logging/setLevel."""
10828+ async def test_logging_set_level_allowed_with_admin_system_config (self):
10829+ """Token scoped to admin.system_config should be allowed logging/setLevel."""
1078910830 payload = {"jsonrpc": "2.0", "id": 1, "method": "logging/setLevel", "params": {"level": "error"}}
10790- request = self._make_request(payload, scoped_permissions=["servers.use "])
10831+ request = self._make_request(payload, scoped_permissions=["admin.system_config "])
1079110832
1079210833 result = await handle_rpc(request, db=MagicMock(), user={"email": "user@example.com"})
1079310834 assert "error" not in result
1079410835
10795- async def test_logging_set_level_denied_without_servers_use (self):
10796- """Token scoped to tools.read only (no servers.use) should be denied logging/setLevel."""
10836+ async def test_logging_set_level_denied_without_admin_system_config (self):
10837+ """Token scoped without admin.system_config should be denied logging/setLevel."""
1079710838 payload = {"jsonrpc": "2.0", "id": 1, "method": "logging/setLevel", "params": {"level": "error"}}
10798- request = self._make_request(payload, scoped_permissions=["tools.read "])
10839+ request = self._make_request(payload, scoped_permissions=["servers.use "])
1079910840
1080010841 result = await handle_rpc(request, db=MagicMock(), user={"email": "user@example.com"})
1080110842 assert result["error"]["code"] == -32003
0 commit comments