gabriel / musehub public
stdio_server.py python
119 lines 3.4 KB
Raw
sha256:1ccb8409daa5aabe577bd26d11b56ed12f3376d64011d0e75a247e81211a66ee docs(mwp4/phase5): tick Phase 5 checkboxes, close musehub#109 Sonnet 4.6 21 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
48 # Initialise DB before handling any requests.
49 try:
50 await init_db()
51 logger.info("MuseHub MCP stdio server started")
52 except Exception as exc:
53 logger.warning("DB init failed (tools requiring DB will return db_unavailable): %s", exc)
54
55 loop = asyncio.get_event_loop()
56 reader = asyncio.StreamReader()
57 protocol = asyncio.StreamReaderProtocol(reader)
58 await loop.connect_read_pipe(lambda: protocol, sys.stdin)
59
60 stdout_transport, stdout_protocol = await loop.connect_write_pipe(
61 asyncio.BaseProtocol, sys.stdout
62 )
63
64 while True:
65 try:
66 line_bytes = await reader.readline()
67 except asyncio.IncompleteReadError:
68 break
69 if not line_bytes:
70 break
71
72 line = line_bytes.decode("utf-8").strip()
73 if not line:
74 continue
75
76 try:
77 raw = json.loads(line)
78 except json.JSONDecodeError as exc:
79 error_resp = {
80 "jsonrpc": "2.0",
81 "id": None,
82 "error": {"code": -32700, "message": f"Parse error: {exc}"},
83 }
84 _write_json(stdout_transport, error_resp)
85 continue
86
87 try:
88 resp = await handle_request(raw, user_id="stdio-user")
89 except Exception as exc:
90 logger.exception("Dispatcher error: %s", exc)
91 resp = {
92 "jsonrpc": "2.0",
93 "id": raw.get("id"),
94 "error": {"code": -32603, "message": f"Internal error: {exc}"},
95 }
96
97 if resp is not None:
98 _write_json(stdout_transport, resp)
99
100
101 def _write_json(transport: asyncio.BaseTransport, obj: JSONObject) -> None:
102 """Serialise ``obj`` to JSON and write it as a single newline-terminated line."""
103 line = f"{json.dumps(obj, default=str)}\n"
104 if isinstance(transport, asyncio.WriteTransport):
105 transport.write(line.encode("utf-8"))
106
107
108 def main() -> None:
109 """Stdio server entry point."""
110 logging.basicConfig(
111 level=logging.INFO,
112 format="%(asctime)s %(levelname)s %(name)s: %(message)s",
113 stream=sys.stderr,
114 )
115 asyncio.run(_run())
116
117
118 if __name__ == "__main__":
119 main()
File History 2 commits
sha256:1ccb8409daa5aabe577bd26d11b56ed12f3376d64011d0e75a247e81211a66ee docs(mwp4/phase5): tick Phase 5 checkboxes, close musehub#109 Sonnet 4.6 21 days ago
sha256:2c523da45351334b5c4dbefed4dc3dd553b3faa8737a4e6caf301e5dc82141be test(mwp4): Phase 0 RED reproduction tests for RC-4 ordering race Sonnet 4.6 21 days ago