@@ -2709,6 +2709,77 @@ async def keycloak_oauth_token(
27092709 )
27102710
27112711
2712+ @pytest .fixture (scope = "session" )
2713+ async def keycloak_oauth_token_read_only (
2714+ anyio_backend , browser , keycloak_oauth_client_credentials , oauth_callback_server
2715+ ) -> str :
2716+ """
2717+ Fixture to obtain a Keycloak OAuth token with only read scopes.
2718+
2719+ This token will only be able to perform read operations and should
2720+ have write tools filtered out from the tool list.
2721+
2722+ Returns:
2723+ OAuth access token from Keycloak for test_read_only user with read-only scopes
2724+ """
2725+ return await _get_keycloak_oauth_token (
2726+ browser ,
2727+ keycloak_oauth_client_credentials ,
2728+ oauth_callback_server ,
2729+ scopes = DEFAULT_READ_SCOPES ,
2730+ username = "test_read_only" ,
2731+ password = "test123" ,
2732+ )
2733+
2734+
2735+ @pytest .fixture (scope = "session" )
2736+ async def keycloak_oauth_token_write_only (
2737+ anyio_backend , browser , keycloak_oauth_client_credentials , oauth_callback_server
2738+ ) -> str :
2739+ """
2740+ Fixture to obtain a Keycloak OAuth token with only write scopes.
2741+
2742+ This token will only be able to perform write operations and should
2743+ have read tools filtered out from the tool list.
2744+
2745+ Returns:
2746+ OAuth access token from Keycloak for test_write_only user with write-only scopes
2747+ """
2748+ return await _get_keycloak_oauth_token (
2749+ browser ,
2750+ keycloak_oauth_client_credentials ,
2751+ oauth_callback_server ,
2752+ scopes = DEFAULT_WRITE_SCOPES ,
2753+ username = "test_write_only" ,
2754+ password = "test123" ,
2755+ )
2756+
2757+
2758+ @pytest .fixture (scope = "session" )
2759+ async def keycloak_oauth_token_no_custom_scopes (
2760+ anyio_backend , browser , keycloak_oauth_client_credentials , oauth_callback_server
2761+ ) -> str :
2762+ """
2763+ Fixture to obtain a Keycloak OAuth token with NO custom scopes.
2764+
2765+ Tests the security behavior when a user grants only default OIDC scopes
2766+ (openid, profile, email) but declines application-specific scopes.
2767+
2768+ Expected behavior: Should see 0 tools (all tools require custom scopes).
2769+
2770+ Returns:
2771+ OAuth access token from Keycloak for test_no_scopes user with no custom scopes
2772+ """
2773+ return await _get_keycloak_oauth_token (
2774+ browser ,
2775+ keycloak_oauth_client_credentials ,
2776+ oauth_callback_server ,
2777+ scopes = "openid profile email" , # No custom scopes
2778+ username = "test_no_scopes" ,
2779+ password = "test123" ,
2780+ )
2781+
2782+
27122783@pytest .fixture (scope = "session" )
27132784async def nc_mcp_keycloak_client (
27142785 anyio_backend , keycloak_oauth_token
@@ -2739,3 +2810,79 @@ async def nc_mcp_keycloak_client(
27392810 logger .info ("✓ MCP client session established with Keycloak authentication" )
27402811 yield session
27412812 logger .info ("✓ MCP client session closed" )
2813+
2814+
2815+ @pytest .fixture (scope = "session" )
2816+ async def nc_mcp_keycloak_client_read_only (
2817+ anyio_backend , keycloak_oauth_token_read_only
2818+ ) -> AsyncGenerator [ClientSession , Any ]:
2819+ """
2820+ MCP client session authenticated with Keycloak read-only token.
2821+
2822+ This client should only see read tools and should get filtered
2823+ write tools based on token scopes.
2824+
2825+ Uses JWT tokens because they embed scope information in claims,
2826+ enabling proper scope-based tool filtering.
2827+ """
2828+ mcp_url = "http://localhost:8002/mcp"
2829+ logger .info (f"Creating read-only MCP client session for Keycloak at { mcp_url } " )
2830+
2831+ async for session in create_mcp_client_session (
2832+ url = mcp_url ,
2833+ token = keycloak_oauth_token_read_only ,
2834+ client_name = "Keycloak Read-Only MCP" ,
2835+ ):
2836+ yield session
2837+
2838+
2839+ @pytest .fixture (scope = "session" )
2840+ async def nc_mcp_keycloak_client_write_only (
2841+ anyio_backend , keycloak_oauth_token_write_only
2842+ ) -> AsyncGenerator [ClientSession , Any ]:
2843+ """
2844+ MCP client session authenticated with Keycloak write-only token.
2845+
2846+ This client should only see write tools and should get filtered
2847+ read tools based on token scopes.
2848+
2849+ Uses JWT tokens because they embed scope information in claims,
2850+ enabling proper scope-based tool filtering.
2851+ """
2852+ mcp_url = "http://localhost:8002/mcp"
2853+ logger .info (f"Creating write-only MCP client session for Keycloak at { mcp_url } " )
2854+
2855+ async for session in create_mcp_client_session (
2856+ url = mcp_url ,
2857+ token = keycloak_oauth_token_write_only ,
2858+ client_name = "Keycloak Write-Only MCP" ,
2859+ ):
2860+ yield session
2861+
2862+
2863+ @pytest .fixture (scope = "session" )
2864+ async def nc_mcp_keycloak_client_no_custom_scopes (
2865+ anyio_backend , keycloak_oauth_token_no_custom_scopes
2866+ ) -> AsyncGenerator [ClientSession , Any ]:
2867+ """
2868+ MCP client session authenticated with Keycloak token without custom scopes.
2869+
2870+ This client has only OIDC default scopes (openid, profile, email) without
2871+ application-specific scopes (notes:read, notes:write, etc.).
2872+
2873+ Expected behavior: Should see 0 tools (all tools require custom scopes).
2874+
2875+ Uses JWT tokens because they embed scope information in claims,
2876+ enabling proper scope-based tool filtering.
2877+ """
2878+ mcp_url = "http://localhost:8002/mcp"
2879+ logger .info (
2880+ f"Creating no-custom-scopes MCP client session for Keycloak at { mcp_url } "
2881+ )
2882+
2883+ async for session in create_mcp_client_session (
2884+ url = mcp_url ,
2885+ token = keycloak_oauth_token_no_custom_scopes ,
2886+ client_name = "Keycloak No Custom Scopes MCP" ,
2887+ ):
2888+ yield session
0 commit comments