fix(host-agent): use stable ServerSession id for MCP lock identity
The previous _current_session_id() implementation tried to import a non-existent get_context() helper, so the production code path always fell through to the empty _TEST_SESSION_ID ContextVar — meaning every MCP client shared the empty-string identity and there was no per-session isolation in production. Use Context.session (the long-lived ServerSession object) as the source of identity. id(ctx.session) is stable across every tool call the same client makes within a Streamable HTTP session, which is exactly what the busy tracker needs to renew leases. Wire FastMCP to inject the Context into the wrapper by setting tool.context_kwarg = "ctx" after swapping tool.fn; wrap the swap in a defensive try/except that surfaces a FastMcpSdkIncompatibilityError on future SDK layout drift. Add 4 tests covering the production path: stability across calls in the same session, isolation between sessions, fallback to _TEST_SESSION_ID when no Context is supplied, and verification that the registered tool declares context_kwarg="ctx". Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -13,8 +13,11 @@ from host_agent.status import AgentStatusTracker
|
||||
from host_agent.web.mcp import (
|
||||
McpDeviceBusyError,
|
||||
_call_tool_sync,
|
||||
_current_session_id,
|
||||
build_mcp_server,
|
||||
)
|
||||
from mcp.server.fastmcp import Context
|
||||
from mcp.shared.context import RequestContext
|
||||
|
||||
|
||||
class _FakeDriver(Driver):
|
||||
@@ -223,4 +226,85 @@ def test_manager_required_for_build_mcp_server() -> None:
|
||||
import inspect
|
||||
|
||||
sig = inspect.signature(build_mcp_server)
|
||||
assert sig.parameters["manager"].kind == inspect.Parameter.KEYWORD_ONLY
|
||||
assert sig.parameters["manager"].kind == inspect.Parameter.KEYWORD_ONLY
|
||||
|
||||
|
||||
def _fake_ctx(session_obj: object) -> Context:
|
||||
"""Build a Context whose ``session`` attribute returns ``session_obj``.
|
||||
|
||||
Context's ``session`` is a property backed by ``request_context.session``;
|
||||
we construct a minimal ``RequestContext`` and set it as the private
|
||||
``_request_context`` field. The pydantic public API doesn't expose a
|
||||
setter for ``session``, so we use ``object.__setattr__`` on the private
|
||||
backing field.
|
||||
"""
|
||||
ctx = Context.model_construct()
|
||||
request_ctx = RequestContext(
|
||||
request_id="req-test",
|
||||
meta=None,
|
||||
session=session_obj,
|
||||
lifespan_context=None,
|
||||
)
|
||||
object.__setattr__(ctx, "_request_context", request_ctx)
|
||||
return ctx
|
||||
|
||||
|
||||
def test_current_session_id_is_stable_across_calls_same_session() -> None:
|
||||
"""Production-path identity: two tool calls from the same MCP session
|
||||
must yield the same session_id so the busy tracker can renew the lease.
|
||||
|
||||
This exercises the ``Context.session`` code path (NOT the
|
||||
``_TEST_SESSION_ID`` fallback used by ``_call_tool_sync``)."""
|
||||
sentinel_session = object()
|
||||
ctx = _fake_ctx(sentinel_session)
|
||||
first = _current_session_id(ctx)
|
||||
second = _current_session_id(ctx)
|
||||
assert first == second
|
||||
assert first.startswith("mcp_session:")
|
||||
# Object identity of the underlying ServerSession is the key — verifies
|
||||
# we use id(ctx.session) rather than e.g. ctx.request_id.
|
||||
assert first == f"mcp_session:{id(sentinel_session)}"
|
||||
|
||||
|
||||
def test_current_session_id_differs_across_sessions() -> None:
|
||||
"""Two different MCP sessions (distinct ServerSession objects) must
|
||||
produce distinct session_ids so the busy tracker can isolate them."""
|
||||
sess_a = object()
|
||||
sess_b = object()
|
||||
assert _current_session_id(_fake_ctx(sess_a)) != _current_session_id(
|
||||
_fake_ctx(sess_b)
|
||||
)
|
||||
|
||||
|
||||
def test_current_session_id_falls_back_when_no_context() -> None:
|
||||
"""When no Context is available (e.g. outside a FastMCP request lifecycle,
|
||||
or via ``_call_tool_sync`` which omits the ctx kwarg), the test
|
||||
contextvars override provides the session_id."""
|
||||
token = None
|
||||
try:
|
||||
from host_agent.web import mcp as mcp_mod
|
||||
|
||||
token = mcp_mod._TEST_SESSION_ID.set("test-session-xyz")
|
||||
assert _current_session_id(None) == "test-session-xyz"
|
||||
finally:
|
||||
if token is not None:
|
||||
from host_agent.web import mcp as mcp_mod
|
||||
|
||||
mcp_mod._TEST_SESSION_ID.reset(token)
|
||||
|
||||
|
||||
def test_wrapped_tool_accepts_context_kwarg() -> None:
|
||||
"""The wrapper registered on FastMCP must declare a ``ctx`` parameter so
|
||||
FastMCP injects the live Context (and ``tool.context_kwarg`` is set to
|
||||
``"ctx"``). Without this, FastMCP never injects context and we fall
|
||||
back to the empty test default — the production bug this PR fixes."""
|
||||
manager = _make_manager_with_device()
|
||||
tracker = McpBusyTracker()
|
||||
status = AgentStatusTracker()
|
||||
server = build_mcp_server(
|
||||
manager=manager, mcp_busy_tracker=tracker, status_tracker=status
|
||||
)
|
||||
tool_manager = server._tool_manager # type: ignore[attr-defined]
|
||||
tool = tool_manager.get_tool("take_screenshot")
|
||||
assert tool is not None
|
||||
assert tool.context_kwarg == "ctx"
|
||||
Reference in New Issue
Block a user