Fix session visibility marks leaking across sessions (#3132)

This commit is contained in:
Jeremiah Lowin 2026-02-10 11:42:43 -05:00 committed by GitHub
commit 40e80d60e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 188 additions and 36 deletions

View file

@ -171,23 +171,23 @@ class Visibility(Transform):
return self.tags is None or bool(component.tags & self.tags)
def _mark_component(self, component: T) -> T:
"""Set visibility state in component metadata if rule matches."""
"""Set visibility state in component metadata if rule matches.
Returns a copy of the component with updated metadata to avoid
mutating shared objects cached in providers.
"""
if not self._matches(component):
return component
# Create new dicts to avoid mutating shared dicts
# (e.g., when Tool.from_tool shares the meta dict between tools)
if component.meta is None:
component.meta = {
_FASTMCP_KEY: {_INTERNAL_KEY: {"visibility": self._enabled}}
}
new_meta = {_FASTMCP_KEY: {_INTERNAL_KEY: {"visibility": self._enabled}}}
else:
old_fastmcp = component.meta.get(_FASTMCP_KEY, {})
old_internal = old_fastmcp.get(_INTERNAL_KEY, {})
new_internal = {**old_internal, "visibility": self._enabled}
new_fastmcp = {**old_fastmcp, _INTERNAL_KEY: new_internal}
component.meta = {**component.meta, _FASTMCP_KEY: new_fastmcp}
return component
new_meta = {**component.meta, _FASTMCP_KEY: new_fastmcp}
return component.model_copy(update={"meta": new_meta})
# -------------------------------------------------------------------------
# Transform methods (mark components, don't filter)

View file

@ -618,3 +618,151 @@ class TestConcurrentSessionIsolation:
assert results[f"non_activated_{i}"] is False, (
f"Non-activated session {i} should NOT see premium tool"
)
class TestSessionVisibilityResetBug:
"""Regression tests for #3034: visibility marks leak via shared component mutation."""
async def test_disable_then_reset_restores_tools(self):
"""After disable + reset within the same session, tools should reappear."""
from fastmcp import Client
mcp = FastMCP("test")
@mcp.tool(tags={"system"})
def my_tool() -> str:
return "hello"
@mcp.tool(tags={"env"})
async def enter_env(ctx: Context) -> str:
await ctx.disable_components(tags={"system"})
return "entered"
@mcp.tool(tags={"env"})
async def exit_env(ctx: Context) -> str:
await ctx.reset_visibility()
return "exited"
async with Client(mcp) as client:
# Tool visible initially
tools = await client.list_tools()
assert any(t.name == "my_tool" for t in tools)
# Disable it
await client.call_tool("enter_env", {})
tools = await client.list_tools()
assert not any(t.name == "my_tool" for t in tools)
# Reset — tool should come back
await client.call_tool("exit_env", {})
tools = await client.list_tools()
assert any(t.name == "my_tool" for t in tools), (
"Tool should be visible again after reset_visibility"
)
async def test_disable_reset_loop(self):
"""Repeated disable/reset cycles should work every time (the exact bug from #3034)."""
from fastmcp import Client
mcp = FastMCP("test")
@mcp.tool(tags={"system"})
def create_project() -> str:
return "created"
@mcp.tool(tags={"env"})
async def enter_env(ctx: Context) -> str:
await ctx.disable_components(tags={"system"})
return "entered"
@mcp.tool(tags={"env"})
async def exit_env(ctx: Context) -> str:
await ctx.reset_visibility()
return "exited"
async with Client(mcp) as client:
for i in range(3):
# create_project should be visible
tools = await client.list_tools()
assert any(t.name == "create_project" for t in tools), (
f"Iteration {i}: create_project should be visible before enter_env"
)
# Enter env — disables system tools
await client.call_tool("enter_env", {})
tools = await client.list_tools()
assert not any(t.name == "create_project" for t in tools), (
f"Iteration {i}: create_project should be hidden after enter_env"
)
# Exit env — reset
await client.call_tool("exit_env", {})
async def test_session_disable_does_not_leak_to_concurrent_session(self):
"""Disabling tools in one session must not affect a concurrent session."""
from fastmcp import Client
mcp = FastMCP("test")
@mcp.tool(tags={"system"})
def shared_tool() -> str:
return "shared"
@mcp.tool
async def disable_system(ctx: Context) -> str:
await ctx.disable_components(tags={"system"})
return "disabled"
session_b_sees_tool = False
ready = anyio.Event()
check_done = anyio.Event()
async def session_a():
async with Client(mcp) as client:
await client.call_tool("disable_system", {})
ready.set()
await check_done.wait()
async def session_b():
nonlocal session_b_sees_tool
await ready.wait()
async with Client(mcp) as client:
tools = await client.list_tools()
session_b_sees_tool = any(t.name == "shared_tool" for t in tools)
check_done.set()
async with anyio.create_task_group() as tg:
tg.start_soon(session_a)
tg.start_soon(session_b)
assert session_b_sees_tool is True, (
"Session B should still see shared_tool despite Session A disabling it"
)
async def test_session_disable_does_not_leak_to_sequential_session(self):
"""Disabling tools in one session must not affect a later session."""
from fastmcp import Client
mcp = FastMCP("test")
@mcp.tool(tags={"system"})
def shared_tool() -> str:
return "shared"
@mcp.tool
async def disable_system(ctx: Context) -> str:
await ctx.disable_components(tags={"system"})
return "disabled"
# Session A disables the tool (no reset)
async with Client(mcp) as client_a:
await client_a.call_tool("disable_system", {})
tools = await client_a.list_tools()
assert not any(t.name == "shared_tool" for t in tools)
# Session B should see it fresh
async with Client(mcp) as client_b:
tools = await client_b.list_tools()
assert any(t.name == "shared_tool" for t in tools), (
"New session should see shared_tool regardless of previous session"
)

View file

@ -101,36 +101,39 @@ class TestMarking:
def test_disable_marks_as_disabled(self):
"""Visibility(False, ...) marks matching components as disabled."""
tool = Tool(name="foo", parameters={})
Visibility(False, names={"foo"})._mark_component(tool)
assert is_enabled(tool) is False
marked = Visibility(False, names={"foo"})._mark_component(tool)
assert is_enabled(marked) is False
def test_enable_marks_as_enabled(self):
"""Visibility(True, ...) marks matching components as enabled."""
tool = Tool(name="foo", parameters={})
Visibility(True, names={"foo"})._mark_component(tool)
assert is_enabled(tool) is True
assert tool.meta is not None
assert tool.meta["fastmcp"]["_internal"]["visibility"] is True
marked = Visibility(True, names={"foo"})._mark_component(tool)
assert is_enabled(marked) is True
assert marked.meta is not None
assert marked.meta["fastmcp"]["_internal"]["visibility"] is True
def test_non_matching_unchanged(self):
"""Non-matching components are not modified."""
tool = Tool(name="bar", parameters={})
Visibility(False, names={"foo"})._mark_component(tool)
result = Visibility(False, names={"foo"})._mark_component(tool)
# No _internal key added
assert tool.meta is None or "_internal" not in tool.meta.get("fastmcp", {})
assert is_enabled(tool) is True
assert result.meta is None or "_internal" not in result.meta.get("fastmcp", {})
assert is_enabled(result) is True
def test_mutates_in_place(self):
"""Marking mutates the component in place."""
def test_returns_copy_for_matching(self):
"""Marking returns a copy to avoid mutating shared provider objects."""
tool = Tool(name="foo", parameters={})
result = Visibility(False, names={"foo"})._mark_component(tool)
assert result is tool
assert result is not tool
assert is_enabled(result) is False
# Original is untouched
assert is_enabled(tool) is True
def test_disable_all(self):
"""match_all=True disables all components."""
tool = Tool(name="anything", parameters={})
Visibility(False, match_all=True)._mark_component(tool)
assert is_enabled(tool) is False
marked = Visibility(False, match_all=True)._mark_component(tool)
assert is_enabled(marked) is False
class TestOverride:
@ -139,20 +142,20 @@ class TestOverride:
def test_enable_overrides_disable(self):
"""An enable after disable results in enabled."""
tool = Tool(name="foo", parameters={})
Visibility(False, names={"foo"})._mark_component(tool)
assert is_enabled(tool) is False
marked = Visibility(False, names={"foo"})._mark_component(tool)
assert is_enabled(marked) is False
Visibility(True, names={"foo"})._mark_component(tool)
assert is_enabled(tool) is True
marked = Visibility(True, names={"foo"})._mark_component(marked)
assert is_enabled(marked) is True
def test_disable_overrides_enable(self):
"""A disable after enable results in disabled."""
tool = Tool(name="foo", parameters={})
Visibility(True, names={"foo"})._mark_component(tool)
assert is_enabled(tool) is True
marked = Visibility(True, names={"foo"})._mark_component(tool)
assert is_enabled(marked) is True
Visibility(False, names={"foo"})._mark_component(tool)
assert is_enabled(tool) is False
marked = Visibility(False, names={"foo"})._mark_component(marked)
assert is_enabled(marked) is False
class TestHelperFunctions:
@ -169,9 +172,10 @@ class TestHelperFunctions:
Tool(name="enabled", parameters={}),
Tool(name="disabled", parameters={}),
]
Visibility(False, names={"disabled"})._mark_component(tools[1])
vis = Visibility(False, names={"disabled"})
marked_tools = [vis._mark_component(t) for t in tools]
visible = [t for t in tools if is_enabled(t)]
visible = [t for t in marked_tools if is_enabled(t)]
assert [t.name for t in visible] == ["enabled"]
@ -181,14 +185,14 @@ class TestMetadata:
def test_internal_metadata_stripped_by_get_meta(self):
"""Internal metadata is stripped when calling get_meta()."""
tool = Tool(name="foo", parameters={})
Visibility(True, names={"foo"})._mark_component(tool)
marked = Visibility(True, names={"foo"})._mark_component(tool)
# Raw meta has _internal
assert tool.meta is not None
assert "_internal" in tool.meta.get("fastmcp", {})
assert marked.meta is not None
assert "_internal" in marked.meta.get("fastmcp", {})
# get_meta() strips it
output = tool.get_meta()
output = marked.get_meta()
assert "_internal" not in output.get("fastmcp", {})
def test_user_metadata_preserved(self):