gabriel / musehub public
test_mcp_stdio_session.py python
154 lines 6.6 KB
Raw
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