Skip to content

Commit be0895e

Browse files
fix(auth): filter tools/list against the normalized server name
The tools/list response filter was given the proxy path (e.g. "myserver/mcp") while the access check earlier in the same request used the registered name ("myserver"), stripped via _registered_server_from_proxy_path in _authorize_forwarded_mcp_body. A scope document keyed on the registered name matched nothing under the suffixed key, so every tool was removed. The failure mode is worse than the bug. The client gets a valid JSON-RPC response with an empty tools array: not an error, so nothing surfaces. The connector looks healthy, the server is listed, and it has no tools. The only signal was the filter's own before/after counts in the auth-server log. Strip the transport suffix at the call site, matching what the access check in the same request already did, and document the contract on filter_tools_list_response so a future caller holding a proxy path does not reintroduce it. Two diagnostics, because the reporter is right that the failure mode is worse than the defect: - Log which scope entry matched. before/after counts alone cannot tell a correct filter from a scope-key mismatch; scopes_matched can. - When a server has no server_access entry in ANY of the caller's scopes and all tools were dropped, log ERROR with remediation. That is a configuration error, not an empty allowlist, and the two are indistinguishable in the response. The empty result still stays empty: authorization continues to fail closed, and the response shape is unchanged, so no client sees different behaviour. Only the log output differs. The diagnostic uses get_server_scopes_bulk, so it costs one round-trip regardless of scope count, against the N*M per-tool lookups the filter already issues. The diagnostic is fully guarded: filter_tools_list_response documents "never raises", so a scope-store failure or an unexpected shape must not turn a successful tools/list into a 500. A test pins that. _patch_scope_repo_allow_all now also stubs get_server_scopes_bulk. Without it the diagnostic would receive a bare AsyncMock and the shared helper would let tests pass for the wrong reason. Fixes #1647 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 07b92f4 commit be0895e

2 files changed

Lines changed: 324 additions & 2 deletions

File tree

auth_server/server.py

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1868,6 +1868,56 @@ async def validate_server_tool_access(
18681868
return False # Deny access on error
18691869

18701870

1871+
async def _scopes_with_server_entry(
1872+
server_name: str,
1873+
user_scopes: list[str],
1874+
) -> list[str]:
1875+
"""Return the caller's scopes that carry a ``server_access`` entry for a server.
1876+
1877+
Diagnostics only -- this never affects an authorization decision. It answers
1878+
"did the scope lookup find anything at all for this server", which is what
1879+
separates a configuration error (no entry in any scope, so every tool is
1880+
filtered out) from a correctly empty allowlist (an entry exists but grants no
1881+
tools). Those two are indistinguishable in the response: both produce a valid
1882+
JSON-RPC result with an empty tools array.
1883+
1884+
Uses the bulk lookup, so this costs one round-trip regardless of how many
1885+
scopes the caller has. The per-tool checks in the caller already issue one
1886+
lookup per scope per tool, so this is negligible next to them.
1887+
1888+
Args:
1889+
server_name: Registered server name (the scope key, no transport suffix).
1890+
user_scopes: Scopes resolved for the caller.
1891+
1892+
Returns:
1893+
The matching scope names, in the order the caller's scopes were given.
1894+
Empty when no scope carries an entry for this server. Never raises.
1895+
"""
1896+
matched: list[str] = []
1897+
# The whole body is guarded: the caller documents "never raises", and this
1898+
# runs purely for a log line, so a lookup or shape surprise must never turn
1899+
# a successful tools/list into a 500.
1900+
try:
1901+
scope_repo = get_scope_repository()
1902+
scope_rules = await scope_repo.get_server_scopes_bulk(user_scopes)
1903+
if not isinstance(scope_rules, dict):
1904+
return []
1905+
for scope in user_scopes:
1906+
entries = scope_rules.get(scope)
1907+
if not isinstance(entries, list):
1908+
continue
1909+
for server_config in entries:
1910+
if not isinstance(server_config, dict):
1911+
continue
1912+
if _server_names_match(server_config.get("server"), server_name):
1913+
matched.append(scope)
1914+
break
1915+
except Exception as exc:
1916+
logger.debug(f"_scopes_with_server_entry: scope lookup failed: {exc}")
1917+
return []
1918+
return matched
1919+
1920+
18711921
async def filter_tools_list_response(
18721922
server_name: str,
18731923
user_scopes: list[str],
@@ -1884,7 +1934,11 @@ async def filter_tools_list_response(
18841934
tools: ["*"] / ["all"]).
18851935
18861936
Args:
1887-
server_name: Name of the MCP server whose tools/list is being filtered.
1937+
server_name: The REGISTERED server name, i.e. the scope key, with no
1938+
transport suffix. Callers holding a proxy path (``myserver/mcp``)
1939+
must run it through _registered_server_from_proxy_path first --
1940+
passing the suffixed form silently matches no scope entry and
1941+
filters every tool out (issue #1647).
18881942
user_scopes: Scopes resolved for the caller.
18891943
tools_list: The raw tools array from the upstream JSON-RPC result.
18901944
@@ -1924,10 +1978,32 @@ async def filter_tools_list_response(
19241978
kept.append(tool)
19251979

19261980
after_count = len(kept)
1981+
1982+
# Which scope entry actually matched. Without this the before/after counts
1983+
# alone cannot tell a correct filter from a scope-key mismatch (issue #1647).
1984+
matched_scopes = await _scopes_with_server_entry(server_name, user_scopes)
19271985
logger.info(
19281986
f"filter_tools_list_response: server={server_name} "
1987+
f"scopes_matched={matched_scopes} "
19291988
f"before={before_count} after={after_count}"
19301989
)
1990+
1991+
# An empty result stays empty -- authorization always fails closed. But
1992+
# "no scope entry exists for this server" is a configuration error, not an
1993+
# empty allowlist, and the two are indistinguishable to the client: both
1994+
# yield a valid JSON-RPC result with zero tools, so the connector looks
1995+
# healthy and nothing surfaces to the user. Say so loudly instead.
1996+
if before_count and not after_count and not matched_scopes:
1997+
logger.error(
1998+
f"filter_tools_list_response: server={server_name} has no server_access entry "
1999+
f"in any of the caller's {len(user_scopes)} scope(s), so all {before_count} "
2000+
f"upstream tools were filtered out. The client receives a healthy-looking "
2001+
f"response with zero tools. This is a configuration error, not an empty "
2002+
f"allowlist: add a server_access entry for '{server_name}' to the caller's "
2003+
f"scope document, and check that it uses the registered server name without "
2004+
f"a transport suffix (e.g. 'myserver', not 'myserver/mcp')."
2005+
)
2006+
19312007
return kept
19322008

19332009

@@ -7359,8 +7435,14 @@ async def mcp_proxy(
73597435

73607436
result = parsed.get("result") if isinstance(parsed, dict) else None
73617437
if isinstance(result, dict) and isinstance(result.get("tools"), list):
7438+
# server_name here is the proxy path (e.g. "myserver/mcp"). The scope
7439+
# allowlist is keyed on the registered name, and the access check
7440+
# earlier in this same request already stripped the transport suffix
7441+
# via _authorize_forwarded_mcp_body. Strip it identically, or the
7442+
# filter looks up a key that does not exist and drops every tool
7443+
# (issue #1647).
73627444
filtered = await filter_tools_list_response(
7363-
server_name,
7445+
_registered_server_from_proxy_path(server_name),
73647446
user_scopes,
73657447
result["tools"],
73667448
)

tests/auth_server/unit/test_server.py

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)