gabriel / musehub public
stdio_server.py python
142 lines 4.7 KB
Raw
sha256:64298d820f83e03ad4a1d3dae5a5fe2e57d124c1ebd0d56e99d93e69b595dccc fix: stdio MCP transport never created a session, breaking … Sonnet 5 minor ⚠ breaking 2 days ago
1 """MuseHub MCP stdio transport — local dev and Cursor integration.
2
3 Entry point: ``python -m musehub.mcp.stdio_server``
4
5 Protocol:
6 - Reads newline-delimited JSON-RPC 2.0 messages from ``stdin``
7 - Writes JSON-RPC 2.0 responses to ``stdout`` (one per line)
8 - Logs diagnostic messages to ``stderr``
9 - Processes messages sequentially (no concurrency concerns)
10 - Notifications (no ``id``) produce no output (202-equivalent silence)
11
12 Auth:
13 - No auth required — stdio is a trusted local process.
14 - All tools including write tools are available.
15 - ``user_id`` defaults to ``"stdio-user"`` so write tools can record an author.
16
17 Usage:
18 # Direct:
19 python -m musehub.mcp.stdio_server
20
21 # Via Cursor (.cursor/mcp.json):
22 {
23 "mcpServers": {
24 "musehub": {
25 "command": "python",
26 "args": ["-m", "musehub.mcp.stdio_server"],
27 "env": {"DATABASE_URL": "postgresql+asyncpg://..."}
28 }
29 }
30 }
31 """
32
33 import asyncio
34 import json
35 import logging
36 import sys
37
38 from musehub.types.json_types import JSONObject
39
40 logger = logging.getLogger("musehub.mcp.stdio")
41
42
43 async def _run() -> None:
44 """Main async stdio loop."""
45 from musehub.db import init_db
46 from musehub.mcp.dispatcher import handle_request
47 from musehub.mcp.session import MCPSession, create_session_from_initialize_params
48
49 # Initialise DB before handling any requests.
50 try:
51 await init_db()
52 logger.info("MuseHub MCP stdio server started")
53 except Exception as exc:
54 logger.warning("DB init failed (tools requiring DB will return db_unavailable): %s", exc)
55
56 loop = asyncio.get_event_loop()
57 reader = asyncio.StreamReader()
58 protocol = asyncio.StreamReaderProtocol(reader)
59 await loop.connect_read_pipe(lambda: protocol, sys.stdin)
60
61 stdout_transport, stdout_protocol = await loop.connect_write_pipe(
62 asyncio.BaseProtocol, sys.stdout
63 )
64
65 # musehub#210: stdio is a single, long-lived connection to exactly one
66 # client for the lifetime of this process -- unlike the Streamable HTTP
67 # transport, there is no Mcp-Session-Id header to key a session lookup
68 # by. Without a session object at all, musehub_set_context's write to
69 # session.repo_focus never happened (guarded by `if ctx.session is not
70 # None`), so the very next musehub_read_context() call -- with no
71 # session to inject owner/slug from -- resolved to "". Create one
72 # session right after a successful `initialize`, the same moment the
73 # HTTP transport does, and reuse it for every request thereafter.
74 session: MCPSession | None = None
75
76 while True:
77 try:
78 line_bytes = await reader.readline()
79 except asyncio.IncompleteReadError:
80 break
81 if not line_bytes:
82 break
83
84 line = line_bytes.decode("utf-8").strip()
85 if not line:
86 continue
87
88 try:
89 raw = json.loads(line)
90 except json.JSONDecodeError as exc:
91 error_resp = {
92 "jsonrpc": "2.0",
93 "id": None,
94 "error": {"code": -32700, "message": f"Parse error: {exc}"},
95 }
96 _write_json(stdout_transport, error_resp)
97 continue
98
99 try:
100 resp = await handle_request(raw, user_id="stdio-user", session=session)
101 except Exception as exc:
102 logger.exception("Dispatcher error: %s", exc)
103 resp = {
104 "jsonrpc": "2.0",
105 "id": raw.get("id"),
106 "error": {"code": -32603, "message": f"Internal error: {exc}"},
107 }
108
109 if resp is not None and raw.get("method") == "initialize" and "error" not in resp:
110 from musehub.mcp.session import SessionCapacityError
111 try:
112 session = create_session_from_initialize_params(raw, "stdio-user")
113 except SessionCapacityError as exc:
114 logger.warning(
115 "Could not create stdio MCP session (%s) — "
116 "musehub_set_context session focus will not persist.",
117 exc,
118 )
119
120 if resp is not None:
121 _write_json(stdout_transport, resp)
122
123
124 def _write_json(transport: asyncio.BaseTransport, obj: JSONObject) -> None:
125 """Serialise ``obj`` to JSON and write it as a single newline-terminated line."""
126 line = f"{json.dumps(obj, default=str)}\n"
127 if isinstance(transport, asyncio.WriteTransport):
128 transport.write(line.encode("utf-8"))
129
130
131 def main() -> None:
132 """Stdio server entry point."""
133 logging.basicConfig(
134 level=logging.INFO,
135 format="%(asctime)s %(levelname)s %(name)s: %(message)s",
136 stream=sys.stderr,
137 )
138 asyncio.run(_run())
139
140
141 if __name__ == "__main__":
142 main()
File History 1 commit
sha256:64298d820f83e03ad4a1d3dae5a5fe2e57d124c1ebd0d56e99d93e69b595dccc fix: stdio MCP transport never created a session, breaking … Sonnet 5 minor ⚠ 2 days ago