test_mcp_stdio_session.py
python
sha256:64298d820f83e03ad4a1d3dae5a5fe2e57d124c1ebd0d56e99d93e69b595dccc
fix: stdio MCP transport never created a session, breaking …
Sonnet 5
minor
⚠ breaking
2 days ago
| 1 | """TDD — musehub#210: stdio transport's session focus not honored. |
| 2 | |
| 3 | ``musehub_set_context`` writes to ``session.repo_focus``, and the dispatcher |
| 4 | injects that focus into the next call's arguments when ``owner``/``slug`` |
| 5 | are omitted (see ``_handle_tools_call`` in ``musehub/mcp/dispatcher.py``). |
| 6 | That mechanism itself was never broken. The bug was entirely in |
| 7 | ``musehub/mcp/stdio_server.py``: every ``handle_request()`` call passed |
| 8 | ``session=None`` (the default) — no ``MCPSession`` was ever created for |
| 9 | the stdio transport at all, unlike the Streamable HTTP transport, which |
| 10 | creates one right after a successful ``initialize`` and threads it through |
| 11 | via the ``Mcp-Session-Id`` header on every subsequent request. |
| 12 | |
| 13 | These tests exercise ``stdio_server._run()`` end-to-end against fake |
| 14 | stdin/stdout pipes — the real regression surface — reproducing the |
| 15 | ticket's exact repro sequence (initialize -> musehub_set_context -> |
| 16 | musehub_read_context with no args, single process, sequential requests). |
| 17 | """ |
| 18 | from __future__ import annotations |
| 19 | |
| 20 | import asyncio |
| 21 | import json |
| 22 | from unittest.mock import AsyncMock, MagicMock, patch |
| 23 | |
| 24 | import pytest |
| 25 | |
| 26 | |
| 27 | class _FakeWriteTransport(asyncio.WriteTransport): |
| 28 | """Records every line written to stdout instead of touching a real pipe.""" |
| 29 | |
| 30 | def __init__(self) -> None: |
| 31 | self.written: list[bytes] = [] |
| 32 | |
| 33 | def write(self, data: bytes) -> None: |
| 34 | self.written.append(data) |
| 35 | |
| 36 | def lines(self) -> list[dict]: |
| 37 | return [json.loads(b.decode("utf-8")) for b in self.written] |
| 38 | |
| 39 | |
| 40 | async def _run_stdio_with_lines(lines: list[dict]) -> list[dict]: |
| 41 | """Feed *lines* through stdio_server._run() and return the parsed responses. |
| 42 | |
| 43 | Patches the real stdin/stdout pipe connections with an in-memory |
| 44 | StreamReader and a fake write transport, and patches ``init_db`` so the |
| 45 | test doesn't need a real database. |
| 46 | """ |
| 47 | from musehub.mcp import stdio_server |
| 48 | |
| 49 | reader = asyncio.StreamReader() |
| 50 | for line in lines: |
| 51 | reader.feed_data((json.dumps(line) + "\n").encode("utf-8")) |
| 52 | reader.feed_eof() |
| 53 | |
| 54 | fake_transport = _FakeWriteTransport() |
| 55 | |
| 56 | async def _fake_connect_read_pipe(protocol_factory, pipe): |
| 57 | protocol = protocol_factory() |
| 58 | protocol.connection_made(MagicMock()) |
| 59 | return MagicMock(), protocol |
| 60 | |
| 61 | async def _fake_connect_write_pipe(protocol_factory, pipe): |
| 62 | return fake_transport, protocol_factory() |
| 63 | |
| 64 | loop = asyncio.get_event_loop() |
| 65 | with ( |
| 66 | patch.object(loop, "connect_read_pipe", side_effect=_fake_connect_read_pipe), |
| 67 | patch.object(loop, "connect_write_pipe", side_effect=_fake_connect_write_pipe), |
| 68 | patch("musehub.db.init_db", new_callable=AsyncMock), |
| 69 | ): |
| 70 | # _run() builds its own StreamReader internally; feed ours in its |
| 71 | # place by patching asyncio.StreamReader to return our pre-fed one. |
| 72 | with patch("asyncio.StreamReader", return_value=reader): |
| 73 | await stdio_server._run() |
| 74 | |
| 75 | return fake_transport.lines() |
| 76 | |
| 77 | |
| 78 | @pytest.mark.asyncio |
| 79 | class TestStdioSessionPersistence: |
| 80 | async def test_set_context_then_read_context_resolves_focused_repo(self) -> None: |
| 81 | """The ticket's exact repro: initialize, musehub_set_context, then |
| 82 | musehub_read_context with NO args in the same stdio process. Must |
| 83 | resolve to the focused repo, not an empty-string repository lookup.""" |
| 84 | mock_resolve = AsyncMock(return_value=("repo-123", None)) |
| 85 | mock_read_context = AsyncMock(return_value=MagicMock(ok=True, data={"repo_id": "repo-123"})) |
| 86 | |
| 87 | with ( |
| 88 | patch("musehub.mcp.dispatcher._resolve_repo_id", mock_resolve), |
| 89 | patch( |
| 90 | "musehub.services.musehub_mcp_executor.execute_read_context", |
| 91 | mock_read_context, |
| 92 | ), |
| 93 | ): |
| 94 | responses = await _run_stdio_with_lines([ |
| 95 | {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-11-25"}}, |
| 96 | { |
| 97 | "jsonrpc": "2.0", "id": 2, "method": "tools/call", |
| 98 | "params": {"name": "musehub_set_context", "arguments": {"owner": "gabriel", "slug": "build-with-muse"}}, |
| 99 | }, |
| 100 | { |
| 101 | "jsonrpc": "2.0", "id": 3, "method": "tools/call", |
| 102 | "params": {"name": "musehub_read_context", "arguments": {}}, |
| 103 | }, |
| 104 | ]) |
| 105 | |
| 106 | assert len(responses) == 3 |
| 107 | # musehub_set_context succeeded. |
| 108 | set_context_resp = responses[1] |
| 109 | assert set_context_resp["result"]["isError"] is False |
| 110 | |
| 111 | # musehub_read_context must have been called with the focused repo_id |
| 112 | # resolved from session.repo_focus -- NOT an empty-string lookup. |
| 113 | read_context_resp = responses[2] |
| 114 | assert read_context_resp["result"]["isError"] is False |
| 115 | mock_read_context.assert_awaited_once() |
| 116 | assert mock_read_context.await_args.args[0] == "repo-123" |
| 117 | |
| 118 | # The owner/slug resolution for the focused repo must have been |
| 119 | # triggered by the SESSION injection, not by explicit arguments on |
| 120 | # call 3 (call 3's arguments were empty). |
| 121 | resolved_calls = [c for c in mock_resolve.await_args_list if c.args == ("gabriel", "build-with-muse")] |
| 122 | assert len(resolved_calls) >= 1 |
| 123 | |
| 124 | async def test_session_is_reused_not_recreated_per_message(self) -> None: |
| 125 | """A second initialize mid-stream is a distinct scenario; absent |
| 126 | that, the same MCPSession object must back every call after the |
| 127 | first initialize -- confirmed by set_context's write surviving to |
| 128 | the read_context call three messages later.""" |
| 129 | mock_resolve = AsyncMock(return_value=("repo-456", None)) |
| 130 | mock_read_context = AsyncMock(return_value=MagicMock(ok=True, data={})) |
| 131 | |
| 132 | with ( |
| 133 | patch("musehub.mcp.dispatcher._resolve_repo_id", mock_resolve), |
| 134 | patch( |
| 135 | "musehub.services.musehub_mcp_executor.execute_read_context", |
| 136 | mock_read_context, |
| 137 | ), |
| 138 | ): |
| 139 | responses = await _run_stdio_with_lines([ |
| 140 | {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}, |
| 141 | { |
| 142 | "jsonrpc": "2.0", "id": 2, "method": "tools/call", |
| 143 | "params": {"name": "musehub_set_context", "arguments": {"owner": "acme", "slug": "widgets"}}, |
| 144 | }, |
| 145 | {"jsonrpc": "2.0", "id": 3, "method": "tools/list"}, |
| 146 | { |
| 147 | "jsonrpc": "2.0", "id": 4, "method": "tools/call", |
| 148 | "params": {"name": "musehub_read_context", "arguments": {}}, |
| 149 | }, |
| 150 | ]) |
| 151 | |
| 152 | assert len(responses) == 4 |
| 153 | mock_read_context.assert_awaited_once() |
| 154 | assert mock_read_context.await_args.args[0] == "repo-456" |
File History
1 commit
sha256:64298d820f83e03ad4a1d3dae5a5fe2e57d124c1ebd0d56e99d93e69b595dccc
fix: stdio MCP transport never created a session, breaking …
Sonnet 5
minor
⚠
2 days ago