Skip to content

Commit 6ec5c95

Browse files
committed
fix: harden roots unsupported fallback and deny-all messaging
1 parent 172feda commit 6ec5c95

File tree

2 files changed

+80
-19
lines changed

2 files changed

+80
-19
lines changed

main.py

Lines changed: 57 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,11 @@ def get_entity_filter_type(entity: Any) -> Optional[str]:
162162
"edit_chat_photo": 50 * 1024 * 1024,
163163
}
164164
ROOTS_UNSUPPORTED_ERROR_CODES = {-32601}
165+
ROOTS_STATUS_READY = "ready"
166+
ROOTS_STATUS_NOT_CONFIGURED = "not_configured"
167+
ROOTS_STATUS_UNSUPPORTED_FALLBACK = "unsupported_fallback"
168+
ROOTS_STATUS_CLIENT_DENY_ALL = "client_deny_all"
169+
ROOTS_STATUS_ERROR = "error"
165170

166171

167172
# Error code prefix mapping for better error tracing
@@ -463,29 +468,47 @@ def _ensure_size_within_limit(tool_name: str, candidate: Path) -> Optional[str]:
463468

464469

465470
async def _get_effective_allowed_roots(ctx: Optional[Context]) -> List[Path]:
466-
fallback_roots = list(SERVER_ALLOWED_ROOTS)
467-
if ctx is None:
468-
return fallback_roots
471+
roots, _status = await _get_effective_allowed_roots_with_status(ctx)
472+
return roots
469473

470-
try:
471-
list_roots_result = await ctx.session.list_roots()
472-
except McpError as error:
474+
475+
def _is_roots_unsupported_error(error: Exception) -> bool:
476+
if isinstance(error, McpError):
473477
error_code = getattr(getattr(error, "error", None), "code", None)
474478
error_message = (
475479
getattr(getattr(error, "error", None), "message", None) or str(error)
476480
).lower()
477-
if error_code in ROOTS_UNSUPPORTED_ERROR_CODES or "method not found" in error_message:
478-
# Fallback is allowed only when roots are unsupported in this MCP client session.
479-
return fallback_roots
481+
if error_code in ROOTS_UNSUPPORTED_ERROR_CODES:
482+
return True
483+
return "method not found" in error_message or "not implemented" in error_message
484+
485+
if isinstance(error, NotImplementedError):
486+
return True
487+
if isinstance(error, AttributeError):
488+
return "list_roots" in str(error)
489+
return False
490+
491+
492+
async def _get_effective_allowed_roots_with_status(
493+
ctx: Optional[Context],
494+
) -> tuple[List[Path], str]:
495+
fallback_roots = list(SERVER_ALLOWED_ROOTS)
496+
if ctx is None:
497+
if fallback_roots:
498+
return fallback_roots, ROOTS_STATUS_READY
499+
return [], ROOTS_STATUS_NOT_CONFIGURED
500+
501+
try:
502+
list_roots_result = await ctx.session.list_roots()
503+
except Exception as error:
504+
if _is_roots_unsupported_error(error):
505+
if fallback_roots:
506+
return fallback_roots, ROOTS_STATUS_UNSUPPORTED_FALLBACK
507+
return [], ROOTS_STATUS_NOT_CONFIGURED
480508
logger.error(
481509
"MCP roots request failed; disabling file-path tools for safety.", exc_info=True
482510
)
483-
return []
484-
except Exception:
485-
logger.error(
486-
"Unexpected MCP roots failure; disabling file-path tools for safety.", exc_info=True
487-
)
488-
return []
511+
return [], ROOTS_STATUS_ERROR
489512

490513
client_roots: List[Path] = []
491514
for root in list_roots_result.roots:
@@ -496,17 +519,33 @@ async def _get_effective_allowed_roots(ctx: Optional[Context]) -> List[Path]:
496519
continue
497520

498521
if client_roots:
499-
return _dedupe_paths(client_roots)
522+
return _dedupe_paths(client_roots), ROOTS_STATUS_READY
500523

501524
# Roots API succeeded; an empty roots list is treated as explicit deny-all.
502-
return []
525+
return [], ROOTS_STATUS_CLIENT_DENY_ALL
503526

504527

505528
async def _ensure_allowed_roots(
506529
ctx: Optional[Context], tool_name: str
507530
) -> tuple[List[Path], Optional[str]]:
508-
roots = await _get_effective_allowed_roots(ctx)
531+
roots, status = await _get_effective_allowed_roots_with_status(ctx)
509532
if not roots:
533+
if status == ROOTS_STATUS_CLIENT_DENY_ALL:
534+
return (
535+
[],
536+
(
537+
f"{tool_name} is disabled because the client provided an empty "
538+
"MCP Roots list (deny-all)."
539+
),
540+
)
541+
if status == ROOTS_STATUS_ERROR:
542+
return (
543+
[],
544+
(
545+
f"{tool_name} is disabled because MCP Roots could not be verified safely. "
546+
"Check MCP client/server logs."
547+
),
548+
)
510549
return (
511550
[],
512551
(

test_file_path_security.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,15 @@ def __init__(self, error):
3838
self.session = _FailingSession(error)
3939

4040

41+
class _MissingRootsSession:
42+
pass
43+
44+
45+
class _MissingRootsContext:
46+
def __init__(self):
47+
self.session = _MissingRootsSession()
48+
49+
4150
@pytest.mark.asyncio
4251
async def test_readable_relative_path_resolves_inside_first_server_root(tmp_path, monkeypatch):
4352
root = (tmp_path / "root").resolve()
@@ -139,7 +148,8 @@ async def test_empty_client_roots_disable_file_tools(tmp_path, monkeypatch):
139148
)
140149
assert resolved is None
141150
assert error is not None
142-
assert "disabled" in error
151+
assert "empty MCP Roots list" in error
152+
assert "deny-all" in error
143153

144154

145155
@pytest.mark.asyncio
@@ -154,6 +164,18 @@ async def test_mcp_method_not_found_falls_back_to_server_allowlist(tmp_path, mon
154164
assert roots == [server_root]
155165

156166

167+
@pytest.mark.asyncio
168+
async def test_missing_list_roots_method_falls_back_to_server_allowlist(tmp_path, monkeypatch):
169+
server_root = (tmp_path / "server_root").resolve()
170+
server_root.mkdir(parents=True)
171+
172+
monkeypatch.setattr(main, "SERVER_ALLOWED_ROOTS", [server_root])
173+
ctx = _MissingRootsContext()
174+
175+
roots = await main._get_effective_allowed_roots(ctx)
176+
assert roots == [server_root]
177+
178+
157179
@pytest.mark.asyncio
158180
async def test_unexpected_roots_error_disables_file_path_tools(tmp_path, monkeypatch):
159181
server_root = (tmp_path / "server_root").resolve()

0 commit comments

Comments
 (0)