@@ -3575,7 +3575,13 @@ async def _get_server_scopes(scope_name: str):
35753575 return [{"server" : "*" , "methods" : ["*" ], "tools" : ["*" ]}]
35763576 return []
35773577
3578+ async def _get_server_scopes_bulk (scope_names : list [str ]):
3579+ return {s : await _get_server_scopes (s ) for s in scope_names if await _get_server_scopes (s )}
3580+
35783581 repo .get_server_scopes .side_effect = _get_server_scopes
3582+ # filter_tools_list_response's diagnostic (_scopes_with_server_entry) reads
3583+ # the bulk method; without a stub it would get a bare AsyncMock return value.
3584+ repo .get_server_scopes_bulk .side_effect = _get_server_scopes_bulk
35793585 return patch ("auth_server.server.get_scope_repository" , return_value = repo )
35803586
35813587
@@ -5991,3 +5997,237 @@ async def _boom(token, server):
59915997
59925998 assert response .status_code == 403
59935999 assert vend_called ["n" ] == 0 # denial happened before any vend/outbound work
6000+
6001+
6002+ # =============================================================================
6003+ # TOOLS/LIST FILTER SCOPE-KEY NORMALIZATION (issue #1647)
6004+ # =============================================================================
6005+
6006+
6007+ def _patch_scope_repo (rules_by_scope : dict [str , list [dict ]]):
6008+ """Patch get_scope_repository with an explicit scope -> server_access map.
6009+
6010+ Stubs both the per-scope and the bulk lookup, because
6011+ validate_server_tool_access uses the former and the filter's diagnostic uses
6012+ the latter; stubbing only one lets a test pass for the wrong reason.
6013+ """
6014+ repo = AsyncMock ()
6015+
6016+ async def _get_server_scopes (scope_name : str ):
6017+ return rules_by_scope .get (scope_name , [])
6018+
6019+ async def _get_server_scopes_bulk (scope_names : list [str ]):
6020+ return {s : rules_by_scope [s ] for s in scope_names if rules_by_scope .get (s )}
6021+
6022+ repo .get_server_scopes .side_effect = _get_server_scopes
6023+ repo .get_server_scopes_bulk .side_effect = _get_server_scopes_bulk
6024+ return patch ("auth_server.server.get_scope_repository" , return_value = repo )
6025+
6026+
6027+ class TestToolsListFilterScopeKey :
6028+ """The tools/list filter must get the registered name, not the proxy path.
6029+
6030+ The access check earlier in the same request strips the transport suffix via
6031+ _registered_server_from_proxy_path, but the filter call site did not. A scope
6032+ document keyed on the registered name then matched nothing, so every tool was
6033+ removed and the client got a valid JSON-RPC response with an empty tools
6034+ array: no error, connector looks healthy, server listed with no tools.
6035+ """
6036+
6037+ @pytest .mark .parametrize (
6038+ ("proxy_path" , "expected_scope_key" ),
6039+ [
6040+ ("office-docs/mcp" , "office-docs" ),
6041+ ("office-docs/sse" , "office-docs" ),
6042+ ("office-docs/messages" , "office-docs" ),
6043+ ("office-docs" , "office-docs" ),
6044+ ],
6045+ )
6046+ def test_filter_receives_normalized_server_name (self , proxy_path , expected_scope_key ):
6047+ """The suffixed proxy path must be stripped before the filter sees it."""
6048+ import auth_server .server as server_module
6049+
6050+ seen : dict [str , str ] = {}
6051+
6052+ async def _capture (server_name , user_scopes , tools ):
6053+ seen ["server_name" ] = server_name
6054+ return tools
6055+
6056+ upstream_resp = _build_mock_upstream_response (
6057+ status_code = 200 ,
6058+ headers = {"content-type" : "application/json" },
6059+ body = b'{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"t1"}]}}' ,
6060+ )
6061+
6062+ with (
6063+ _patch_httpx_async_client (upstream_resp ),
6064+ _patch_scope_repo_allow_all (),
6065+ patch .object (server_module , "_read_mcp_filter_enabled" , return_value = True ),
6066+ patch .object (server_module , "filter_tools_list_response" , side_effect = _capture ),
6067+ ):
6068+ client = TestClient (server_module .app )
6069+ response = client .post (
6070+ f"/mcp-proxy/{ proxy_path } " ,
6071+ json = {"jsonrpc" : "2.0" , "id" : 1 , "method" : "tools/list" },
6072+ headers = _mcp_proxy_token_headers (server_name = "office-docs" ),
6073+ )
6074+
6075+ assert response .status_code == 200
6076+ assert seen ["server_name" ] == expected_scope_key
6077+
6078+ def test_federated_peer_server_key_is_preserved (self ):
6079+ """Only the transport tail is stripped; peer/server must survive intact."""
6080+ import auth_server .server as server_module
6081+
6082+ seen : dict [str , str ] = {}
6083+
6084+ async def _capture (server_name , user_scopes , tools ):
6085+ seen ["server_name" ] = server_name
6086+ return tools
6087+
6088+ upstream_resp = _build_mock_upstream_response (
6089+ status_code = 200 ,
6090+ headers = {"content-type" : "application/json" },
6091+ body = b'{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"t1"}]}}' ,
6092+ )
6093+
6094+ with (
6095+ _patch_httpx_async_client (upstream_resp ),
6096+ _patch_scope_repo_allow_all (),
6097+ patch .object (server_module , "_read_mcp_filter_enabled" , return_value = True ),
6098+ patch .object (server_module , "filter_tools_list_response" , side_effect = _capture ),
6099+ ):
6100+ client = TestClient (server_module .app )
6101+ response = client .post (
6102+ "/mcp-proxy/peer-lob-1/cloudflare-docs/mcp" ,
6103+ json = {"jsonrpc" : "2.0" , "id" : 1 , "method" : "tools/list" },
6104+ headers = _mcp_proxy_token_headers (server_name = "peer-lob-1/cloudflare-docs" ),
6105+ )
6106+
6107+ assert response .status_code == 200
6108+ assert seen ["server_name" ] == "peer-lob-1/cloudflare-docs"
6109+
6110+ def test_suffixed_key_would_have_filtered_everything (self ):
6111+ """Pin the failure mode itself, so the bug cannot come back unnoticed.
6112+
6113+ A scope document keyed on the registered name grants nothing when looked
6114+ up under the suffixed path. This asserts the mechanism the fix avoids,
6115+ independently of which call site does the stripping.
6116+ """
6117+ import asyncio
6118+
6119+ from auth_server .server import filter_tools_list_response
6120+
6121+ rules = {"grp" : [{"server" : "office-docs" , "methods" : ["*" ], "tools" : ["t1" , "t2" ]}]}
6122+ tools = [{"name" : "t1" }, {"name" : "t2" }]
6123+
6124+ with _patch_scope_repo (rules ):
6125+ normalized = asyncio .run (filter_tools_list_response ("office-docs" , ["grp" ], tools ))
6126+ suffixed = asyncio .run (filter_tools_list_response ("office-docs/mcp" , ["grp" ], tools ))
6127+
6128+ assert len (normalized ) == 2
6129+ assert suffixed == []
6130+
6131+
6132+ class TestToolsListFilterDiagnostics :
6133+ """A silently empty tools array must be distinguishable from a real denial.
6134+
6135+ The filter fails closed either way -- that is not negotiable -- but "no
6136+ server_access entry exists for this server" is a configuration error, while
6137+ "an entry exists and grants no tools" is a correct empty allowlist. They look
6138+ identical to the client, so the logs have to separate them.
6139+ """
6140+
6141+ def test_missing_scope_entry_logs_error (self , caplog ):
6142+ """No entry anywhere for the server: log ERROR naming the server."""
6143+ import asyncio
6144+
6145+ from auth_server .server import filter_tools_list_response
6146+
6147+ rules = {"grp" : [{"server" : "some-other-server" , "methods" : ["*" ], "tools" : ["*" ]}]}
6148+ tools = [{"name" : "t1" }, {"name" : "t2" }]
6149+
6150+ with _patch_scope_repo (rules ), caplog .at_level (logging .ERROR , logger = "auth_server.server" ):
6151+ kept = asyncio .run (filter_tools_list_response ("office-docs" , ["grp" ], tools ))
6152+
6153+ assert kept == [] # still fails closed
6154+ errors = [r .message for r in caplog .records if r .levelno == logging .ERROR ]
6155+ assert any ("office-docs" in m and "no server_access entry" in m for m in errors ), errors
6156+
6157+ def test_empty_allowlist_does_not_log_error (self , caplog ):
6158+ """An entry that grants no tools is legitimate, so no ERROR."""
6159+ import asyncio
6160+
6161+ from auth_server .server import filter_tools_list_response
6162+
6163+ rules = {"grp" : [{"server" : "office-docs" , "methods" : ["tools/list" ], "tools" : []}]}
6164+ tools = [{"name" : "t1" }]
6165+
6166+ with _patch_scope_repo (rules ), caplog .at_level (logging .ERROR , logger = "auth_server.server" ):
6167+ kept = asyncio .run (filter_tools_list_response ("office-docs" , ["grp" ], tools ))
6168+
6169+ assert kept == []
6170+ assert not [r for r in caplog .records if r .levelno == logging .ERROR ]
6171+
6172+ def test_no_error_when_tools_survive (self , caplog ):
6173+ """A successful filter must not log an ERROR."""
6174+ import asyncio
6175+
6176+ from auth_server .server import filter_tools_list_response
6177+
6178+ rules = {"grp" : [{"server" : "office-docs" , "methods" : ["*" ], "tools" : ["t1" ]}]}
6179+ tools = [{"name" : "t1" }, {"name" : "t2" }]
6180+
6181+ with _patch_scope_repo (rules ), caplog .at_level (logging .INFO , logger = "auth_server.server" ):
6182+ kept = asyncio .run (filter_tools_list_response ("office-docs" , ["grp" ], tools ))
6183+
6184+ assert [t ["name" ] for t in kept ] == ["t1" ]
6185+ assert not [r for r in caplog .records if r .levelno >= logging .ERROR ]
6186+
6187+ def test_info_line_names_the_matching_scope (self , caplog ):
6188+ """before/after counts alone cannot diagnose a scope-key mismatch.
6189+
6190+ Logging which scope entry matched is what turns this class of bug from a
6191+ multi-day hunt into a single grep.
6192+ """
6193+ import asyncio
6194+
6195+ from auth_server .server import filter_tools_list_response
6196+
6197+ rules = {
6198+ "grp-a" : [{"server" : "office-docs" , "methods" : ["*" ], "tools" : ["t1" ]}],
6199+ "grp-b" : [{"server" : "some-other-server" , "methods" : ["*" ], "tools" : ["*" ]}],
6200+ }
6201+ tools = [{"name" : "t1" }]
6202+
6203+ with (
6204+ _patch_scope_repo (rules ),
6205+ caplog .at_level (logging .INFO , logger = "auth_server.server" ),
6206+ ):
6207+ asyncio .run (filter_tools_list_response ("office-docs" , ["grp-a" , "grp-b" ], tools ))
6208+
6209+ lines = [r .message for r in caplog .records if "filter_tools_list_response:" in r .message ]
6210+ assert any ("scopes_matched=['grp-a']" in m for m in lines ), lines
6211+
6212+ def test_diagnostic_never_breaks_the_filter (self ):
6213+ """A broken scope repository must not turn tools/list into a 500.
6214+
6215+ The filter documents "never raises"; the diagnostic is only a log line,
6216+ so a lookup failure or an unexpected shape has to stay swallowed.
6217+ """
6218+ import asyncio
6219+
6220+ from auth_server .server import filter_tools_list_response
6221+
6222+ repo = AsyncMock ()
6223+
6224+ async def _get_server_scopes (scope_name : str ):
6225+ return [{"server" : "office-docs" , "methods" : ["*" ], "tools" : ["*" ]}]
6226+
6227+ repo .get_server_scopes .side_effect = _get_server_scopes
6228+ repo .get_server_scopes_bulk .side_effect = RuntimeError ("scope store unreachable" )
6229+
6230+ with patch ("auth_server.server.get_scope_repository" , return_value = repo ):
6231+ kept = asyncio .run (filter_tools_list_response ("office-docs" , ["grp" ], [{"name" : "t1" }]))
6232+
6233+ assert [t ["name" ] for t in kept ] == ["t1" ]
0 commit comments