"""TDD — musehub#210: stdio transport's session focus not honored. ``musehub_set_context`` writes to ``session.repo_focus``, and the dispatcher injects that focus into the next call's arguments when ``owner``/``slug`` are omitted (see ``_handle_tools_call`` in ``musehub/mcp/dispatcher.py``). That mechanism itself was never broken. The bug was entirely in ``musehub/mcp/stdio_server.py``: every ``handle_request()`` call passed ``session=None`` (the default) — no ``MCPSession`` was ever created for the stdio transport at all, unlike the Streamable HTTP transport, which creates one right after a successful ``initialize`` and threads it through via the ``Mcp-Session-Id`` header on every subsequent request. These tests exercise ``stdio_server._run()`` end-to-end against fake stdin/stdout pipes — the real regression surface — reproducing the ticket's exact repro sequence (initialize -> musehub_set_context -> musehub_read_context with no args, single process, sequential requests). """ from __future__ import annotations import asyncio import json from unittest.mock import AsyncMock, MagicMock, patch import pytest class _FakeWriteTransport(asyncio.WriteTransport): """Records every line written to stdout instead of touching a real pipe.""" def __init__(self) -> None: self.written: list[bytes] = [] def write(self, data: bytes) -> None: self.written.append(data) def lines(self) -> list[dict]: return [json.loads(b.decode("utf-8")) for b in self.written] async def _run_stdio_with_lines(lines: list[dict]) -> list[dict]: """Feed *lines* through stdio_server._run() and return the parsed responses. Patches the real stdin/stdout pipe connections with an in-memory StreamReader and a fake write transport, and patches ``init_db`` so the test doesn't need a real database. """ from musehub.mcp import stdio_server reader = asyncio.StreamReader() for line in lines: reader.feed_data((json.dumps(line) + "\n").encode("utf-8")) reader.feed_eof() fake_transport = _FakeWriteTransport() async def _fake_connect_read_pipe(protocol_factory, pipe): protocol = protocol_factory() protocol.connection_made(MagicMock()) return MagicMock(), protocol async def _fake_connect_write_pipe(protocol_factory, pipe): return fake_transport, protocol_factory() loop = asyncio.get_event_loop() with ( patch.object(loop, "connect_read_pipe", side_effect=_fake_connect_read_pipe), patch.object(loop, "connect_write_pipe", side_effect=_fake_connect_write_pipe), patch("musehub.db.init_db", new_callable=AsyncMock), ): # _run() builds its own StreamReader internally; feed ours in its # place by patching asyncio.StreamReader to return our pre-fed one. with patch("asyncio.StreamReader", return_value=reader): await stdio_server._run() return fake_transport.lines() @pytest.mark.asyncio class TestStdioSessionPersistence: async def test_set_context_then_read_context_resolves_focused_repo(self) -> None: """The ticket's exact repro: initialize, musehub_set_context, then musehub_read_context with NO args in the same stdio process. Must resolve to the focused repo, not an empty-string repository lookup.""" mock_resolve = AsyncMock(return_value=("repo-123", None)) mock_read_context = AsyncMock(return_value=MagicMock(ok=True, data={"repo_id": "repo-123"})) with ( patch("musehub.mcp.dispatcher._resolve_repo_id", mock_resolve), patch( "musehub.services.musehub_mcp_executor.execute_read_context", mock_read_context, ), ): responses = await _run_stdio_with_lines([ {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-11-25"}}, { "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "musehub_set_context", "arguments": {"owner": "gabriel", "slug": "build-with-muse"}}, }, { "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "musehub_read_context", "arguments": {}}, }, ]) assert len(responses) == 3 # musehub_set_context succeeded. set_context_resp = responses[1] assert set_context_resp["result"]["isError"] is False # musehub_read_context must have been called with the focused repo_id # resolved from session.repo_focus -- NOT an empty-string lookup. read_context_resp = responses[2] assert read_context_resp["result"]["isError"] is False mock_read_context.assert_awaited_once() assert mock_read_context.await_args.args[0] == "repo-123" # The owner/slug resolution for the focused repo must have been # triggered by the SESSION injection, not by explicit arguments on # call 3 (call 3's arguments were empty). resolved_calls = [c for c in mock_resolve.await_args_list if c.args == ("gabriel", "build-with-muse")] assert len(resolved_calls) >= 1 async def test_session_is_reused_not_recreated_per_message(self) -> None: """A second initialize mid-stream is a distinct scenario; absent that, the same MCPSession object must back every call after the first initialize -- confirmed by set_context's write surviving to the read_context call three messages later.""" mock_resolve = AsyncMock(return_value=("repo-456", None)) mock_read_context = AsyncMock(return_value=MagicMock(ok=True, data={})) with ( patch("musehub.mcp.dispatcher._resolve_repo_id", mock_resolve), patch( "musehub.services.musehub_mcp_executor.execute_read_context", mock_read_context, ), ): responses = await _run_stdio_with_lines([ {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}, { "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "musehub_set_context", "arguments": {"owner": "acme", "slug": "widgets"}}, }, {"jsonrpc": "2.0", "id": 3, "method": "tools/list"}, { "jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": {"name": "musehub_read_context", "arguments": {}}, }, ]) assert len(responses) == 4 mock_read_context.assert_awaited_once() assert mock_read_context.await_args.args[0] == "repo-456"