1+ """
2+ Minimal async JSON-RPC 2.0 client for stdio transport
3+
4+ This uses threading to handle blocking IO in an async-friendly way.
5+ Much simpler and more reliable than pure asyncio subprocess.
6+ """
7+
8+ import asyncio
9+ import inspect
10+ import json
11+ import threading
12+ import uuid
13+ from collections .abc import Awaitable , Callable
14+ from typing import Any
15+
16+
17+ class JsonRpcError (Exception ):
18+ """JSON-RPC error response"""
19+
20+ def __init__ (self , code : int , message : str , data : Any = None ):
21+ self .code = code
22+ self .message = message
23+ self .data = data
24+ super ().__init__ (f"JSON-RPC Error { code } : { message } " )
25+
26+
27+ class ProcessExitedError (Exception ):
28+ """Error raised when the CLI process exits unexpectedly"""
29+
30+ pass
31+
32+
33+ RequestHandler = Callable [[dict ], dict | Awaitable [dict ]]
34+
35+
36+ class JsonRpcClient :
37+ """
38+ Minimal async JSON-RPC 2.0 client for stdio transport
39+
40+ Uses threads for blocking IO but provides async interface.
41+ """
42+
43+ def __init__ (self , process ):
44+ """
45+ Create client from subprocess.Popen with stdin/stdout pipes
46+
47+ Args:
48+ process: subprocess.Popen with stdin=PIPE, stdout=PIPE
49+ """
50+ self .process = process
51+ self .pending_requests : dict [str , asyncio .Future ] = {}
52+ self .notification_handler : Callable [[str , dict ], None ] | None = None
53+ self .request_handlers : dict [str , RequestHandler ] = {}
54+ self ._running = False
55+ self ._read_thread : threading .Thread | None = None
56+ self ._stderr_thread : threading .Thread | None = None
57+ self ._loop : asyncio .AbstractEventLoop | None = None
58+ self ._write_lock = threading .Lock ()
59+ self ._pending_lock = threading .Lock ()
60+ self ._process_exit_error : str | None = None
61+ self ._stderr_output : list [str ] = []
62+ self ._stderr_lock = threading .Lock ()
63+ self .on_close : Callable [[], None ] | None = None
64+
65+ def start (self , loop : asyncio .AbstractEventLoop | None = None ):
66+ """Start listening for messages in background thread"""
67+ if not self ._running :
68+ self ._running = True
69+ # Always use the provided loop or get the running loop
70+ self ._loop = loop or asyncio .get_running_loop ()
71+ self ._read_thread = threading .Thread (target = self ._read_loop , daemon = True )
72+ self ._read_thread .start ()
73+ # Start stderr reader thread if process has stderr
74+ if hasattr (self .process , "stderr" ) and self .process .stderr :
75+ self ._stderr_thread = threading .Thread (target = self ._stderr_loop , daemon = True )
76+ self ._stderr_thread .start ()
77+
78+ def _stderr_loop (self ):
79+ """Read stderr in background to capture error messages"""
80+ try :
81+ while self ._running :
82+ if not self .process .stderr :
83+ break
84+ line = self .process .stderr .readline ()
85+ if not line :
86+ break
87+ with self ._stderr_lock :
88+ self ._stderr_output .append (
89+ line .decode ("utf-8" ) if isinstance (line , bytes ) else line
90+ )
91+ except Exception :
92+ pass # Ignore errors reading stderr
93+
94+ def get_stderr_output (self ) -> str :
95+ """Get captured stderr output"""
96+ with self ._stderr_lock :
97+ return "" .join (self ._stderr_output ).strip ()
98+
99+ async def stop (self ):
100+ """Stop listening and clean up"""
101+ self ._running = False
102+ if self ._read_thread :
103+ self ._read_thread .join (timeout = 1.0 )
104+ if self ._stderr_thread :
105+ self ._stderr_thread .join (timeout = 1.0 )
106+
107+ async def request (
108+ self , method : str , params : dict | None = None , timeout : float | None = None
109+ ) -> Any :
110+ """
111+ Send a JSON-RPC request and wait for response
112+
113+ Args:
114+ method: Method name
115+ params: Optional parameters
116+ timeout: Optional request timeout in seconds. If None (default),
117+ waits indefinitely for the server to respond.
118+
119+ Returns:
120+ The result from the response
121+
122+ Raises:
123+ JsonRpcError: If server returns an error
124+ asyncio.TimeoutError: If request times out (only when timeout is set)
125+ """
126+ request_id = str (uuid .uuid4 ())
127+
128+ # Use the stored loop to ensure consistency with the reader thread
129+ if not self ._loop :
130+ raise RuntimeError ("Client not started. Call start() first." )
131+
132+ future = self ._loop .create_future ()
133+ with self ._pending_lock :
134+ self .pending_requests [request_id ] = future
135+
136+ message = {
137+ "jsonrpc" : "2.0" ,
138+ "id" : request_id ,
139+ "method" : method ,
140+ "params" : params or {},
141+ }
142+
143+ await self ._send_message (message )
144+
145+ try :
146+ if timeout is not None :
147+ return await asyncio .wait_for (future , timeout = timeout )
148+ return await future
149+ finally :
150+ with self ._pending_lock :
151+ self .pending_requests .pop (request_id , None )
152+
153+ async def notify (self , method : str , params : dict | None = None ):
154+ """
155+ Send a JSON-RPC notification (no response expected)
156+
157+ Args:
158+ method: Method name
159+ params: Optional parameters
160+ """
161+ message = {
162+ "jsonrpc" : "2.0" ,
163+ "method" : method ,
164+ "params" : params or {},
165+ }
166+ await self ._send_message (message )
167+
168+ def set_notification_handler (self , handler : Callable [[str , dict ], None ]):
169+ """Set handler for incoming notifications from server"""
170+ self .notification_handler = handler
171+
172+ def set_request_handler (self , method : str , handler : RequestHandler ):
173+ if handler is None :
174+ self .request_handlers .pop (method , None )
175+ else :
176+ self .request_handlers [method ] = handler
177+
178+ async def _send_message (self , message : dict ):
179+ """Send a JSON-RPC message with Content-Length header"""
180+ loop = self ._loop or asyncio .get_event_loop ()
181+
182+ def write ():
183+ content = json .dumps (message , separators = ("," , ":" ))
184+ content_bytes = content .encode ("utf-8" )
185+ header = f"Content-Length: { len (content_bytes )} \r \n \r \n "
186+ with self ._write_lock :
187+ self .process .stdin .write (header .encode ("utf-8" ))
188+ self .process .stdin .write (content_bytes )
189+ self .process .stdin .flush ()
190+
191+ # Run in thread pool to avoid blocking
192+ await loop .run_in_executor (None , write )
193+
194+ def _read_loop (self ):
195+ """Read messages from the stream (runs in thread)"""
196+ try :
197+ while self ._running :
198+ message = self ._read_message ()
199+ if message :
200+ self ._handle_message (message )
201+ else :
202+ # No message means stream closed - process likely exited
203+ break
204+ except EOFError :
205+ # Stream closed - check if process exited
206+ pass
207+ except Exception as e :
208+ if self ._running :
209+ # Store error for pending requests
210+ self ._process_exit_error = str (e )
211+
212+ # Process exited or read failed - fail all pending requests
213+ if self ._running :
214+ self ._fail_pending_requests ()
215+ if self .on_close is not None :
216+ self .on_close ()
217+
218+ def _fail_pending_requests (self ):
219+ """Fail all pending requests when process exits"""
220+ # Build error message with stderr output
221+ stderr_output = self .get_stderr_output ()
222+ return_code = None
223+ if hasattr (self .process , "poll" ):
224+ return_code = self .process .poll ()
225+
226+ if stderr_output :
227+ error_msg = f"CLI process exited with code { return_code } \n stderr: { stderr_output } "
228+ elif return_code is not None :
229+ error_msg = f"CLI process exited with code { return_code } "
230+ else :
231+ error_msg = "CLI process exited unexpectedly"
232+
233+ # Fail all pending requests
234+ with self ._pending_lock :
235+ for request_id , future in list (self .pending_requests .items ()):
236+ if not future .done ():
237+ exc = ProcessExitedError (error_msg )
238+ loop = future .get_loop ()
239+ loop .call_soon_threadsafe (future .set_exception , exc )
240+
241+ def _read_exact (self , num_bytes : int ) -> bytes :
242+ """
243+ Read exactly num_bytes, handling partial/short reads from pipes.
244+
245+ Args:
246+ num_bytes: Number of bytes to read
247+
248+ Returns:
249+ Bytes read from stream
250+
251+ Raises:
252+ EOFError: If stream ends before reading all bytes
253+ """
254+ chunks = []
255+ remaining = num_bytes
256+ while remaining > 0 :
257+ chunk = self .process .stdout .read (remaining )
258+ if not chunk :
259+ raise EOFError ("Unexpected end of stream while reading JSON-RPC message" )
260+ chunks .append (chunk )
261+ remaining -= len (chunk )
262+ return b"" .join (chunks )
263+
264+ def _read_message (self ) -> dict | None :
265+ """
266+ Read a single JSON-RPC message with Content-Length header (blocking)
267+
268+ Returns:
269+ Parsed JSON message or None if connection closed
270+ """
271+ # Read header line
272+ header_line = self .process .stdout .readline ()
273+ if not header_line :
274+ return None
275+
276+ # Parse Content-Length
277+ header = header_line .decode ("utf-8" ).strip ()
278+ if not header .startswith ("Content-Length:" ):
279+ return None
280+
281+ content_length = int (header .split (":" )[1 ].strip ())
282+
283+ # Read empty line
284+ self .process .stdout .readline ()
285+
286+ # Read exact content using loop to handle short reads
287+ content_bytes = self ._read_exact (content_length )
288+ content = content_bytes .decode ("utf-8" )
289+
290+ return json .loads (content )
291+
292+ def _handle_message (self , message : dict ):
293+ """Handle an incoming message (response or notification)"""
294+ # Check if it's a response to our request
295+ if "id" in message :
296+ with self ._pending_lock :
297+ future = self .pending_requests .get (message ["id" ])
298+
299+ if future is not None :
300+ loop = future .get_loop ()
301+
302+ if "error" in message :
303+ error = message ["error" ]
304+ exc = JsonRpcError (
305+ error .get ("code" , - 1 ),
306+ error .get ("message" , "Unknown error" ),
307+ error .get ("data" ),
308+ )
309+ loop .call_soon_threadsafe (future .set_exception , exc )
310+ elif "result" in message :
311+ loop .call_soon_threadsafe (future .set_result , message ["result" ])
312+ else :
313+ exc = ValueError ("Invalid JSON-RPC response" )
314+ loop .call_soon_threadsafe (future .set_exception , exc )
315+ return
316+
317+ # Check if it's a notification from server
318+ if "method" in message and "id" not in message :
319+ if self .notification_handler and self ._loop :
320+ method = message ["method" ]
321+ params = message .get ("params" , {})
322+ # Schedule notification handler on the event loop for thread safety
323+ self ._loop .call_soon_threadsafe (self .notification_handler , method , params )
324+ return
325+
326+ # Otherwise handle as incoming request (tool.call, etc.)
327+ if "method" in message and "id" in message :
328+ self ._handle_request (message )
329+
330+ def _handle_request (self , message : dict ):
331+ method = message .get ("method" , "" )
332+ handler = self .request_handlers .get (method )
333+ if not handler :
334+ if self ._loop :
335+ asyncio .run_coroutine_threadsafe (
336+ self ._send_error_response (
337+ message ["id" ], - 32601 , f"Method not found: { message ['method' ]} " , None
338+ ),
339+ self ._loop ,
340+ )
341+ return
342+ if not self ._loop :
343+ return
344+ asyncio .run_coroutine_threadsafe (
345+ self ._dispatch_request (message , handler ),
346+ self ._loop ,
347+ )
348+
349+ async def _dispatch_request (self , message : dict , handler : RequestHandler ):
350+ try :
351+ params = message .get ("params" , {})
352+ outcome = handler (params )
353+ if inspect .isawaitable (outcome ):
354+ outcome = await outcome
355+ if outcome is not None and not isinstance (outcome , dict ):
356+ raise ValueError (
357+ f"Request handler must return a dict, got { type (outcome ).__name__ } "
358+ )
359+ await self ._send_response (message ["id" ], outcome )
360+ except JsonRpcError as exc :
361+ await self ._send_error_response (message ["id" ], exc .code , exc .message , exc .data )
362+ except Exception as exc : # pylint: disable=broad-except
363+ await self ._send_error_response (message ["id" ], - 32603 , str (exc ), None )
364+
365+ async def _send_response (self , request_id : str , result : dict | None ):
366+ response = {
367+ "jsonrpc" : "2.0" ,
368+ "id" : request_id ,
369+ "result" : result ,
370+ }
371+ await self ._send_message (response )
372+
373+ async def _send_error_response (
374+ self , request_id : str , code : int , message : str , data : dict | None
375+ ):
376+ response = {
377+ "jsonrpc" : "2.0" ,
378+ "id" : request_id ,
379+ "error" : {
380+ "code" : code ,
381+ "message" : message ,
382+ "data" : data ,
383+ },
384+ }
385+ await self ._send_message (response )
0 commit comments