mcp-proxy-oauth-dcr 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,580 @@
1
+ """Stdio interface for communication with Kiro.
2
+
3
+ This module implements the stdio MCP server interface that Kiro expects,
4
+ handling async stdin/stdout communication, subprocess lifecycle management,
5
+ message queuing, and signal handling.
6
+
7
+ Requirements satisfied:
8
+ - 1.1: Present valid stdio MCP server interface to Kiro
9
+ - 1.4: Maintain message ordering and correlation between stdio and HTTP protocols
10
+ """
11
+
12
+ import asyncio
13
+ import signal
14
+ import sys
15
+ from typing import Optional, Callable, Awaitable, Union
16
+ from asyncio import Queue, StreamReader, StreamWriter
17
+
18
+ from ..models import JsonRpcMessage
19
+ from ..exceptions import ProtocolError, InvalidJsonRpcError
20
+ from .jsonrpc import JsonRpcParser, JsonRpcSerializer
21
+ from ..logging_config import get_logger
22
+
23
+ logger = get_logger(__name__)
24
+
25
+ # Default queue size limit to prevent unbounded memory growth
26
+ DEFAULT_QUEUE_SIZE = 1000
27
+
28
+ # Timeout for graceful shutdown operations
29
+ SHUTDOWN_TIMEOUT = 5.0
30
+
31
+ # Timeout for draining pending messages on shutdown
32
+ DRAIN_TIMEOUT = 2.0
33
+
34
+
35
+ class StdioInterface:
36
+ """Stdio interface for JSON-RPC communication with Kiro.
37
+
38
+ This class manages async stdin/stdout communication, providing a clean
39
+ interface for sending and receiving JSON-RPC messages. It handles:
40
+ - Async reading from stdin with proper buffering
41
+ - Async writing to stdout with message queuing
42
+ - Subprocess lifecycle and signal management
43
+ - Graceful shutdown and cleanup
44
+
45
+ The interface maintains message ordering by using FIFO queues for both
46
+ incoming and outgoing messages, ensuring that messages are processed
47
+ and sent in the order they were received/queued.
48
+
49
+ Attributes:
50
+ is_running: Whether the interface is currently running
51
+ pending_incoming: Number of messages waiting to be processed
52
+ pending_outgoing: Number of messages waiting to be sent
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ message_handler: Optional[Callable[[JsonRpcMessage], Awaitable[Optional[JsonRpcMessage]]]] = None,
58
+ queue_size: int = DEFAULT_QUEUE_SIZE
59
+ ):
60
+ """Initialize the stdio interface.
61
+
62
+ Args:
63
+ message_handler: Optional async callback for handling incoming messages.
64
+ Should accept a JsonRpcMessage and return a JsonRpcMessage response
65
+ or None for notifications.
66
+ queue_size: Maximum size for message queues (default: 1000)
67
+ """
68
+ self._message_handler = message_handler
69
+ self._running = False
70
+ self._shutdown_event = asyncio.Event()
71
+ self._started_event = asyncio.Event()
72
+
73
+ # Message queues for async processing with size limits
74
+ self._incoming_queue: Queue[JsonRpcMessage] = Queue(maxsize=queue_size)
75
+ self._outgoing_queue: Queue[JsonRpcMessage] = Queue(maxsize=queue_size)
76
+
77
+ # Stream readers/writers
78
+ self._stdin_reader: Optional[StreamReader] = None
79
+ self._stdout_writer: Optional[StreamWriter] = None
80
+
81
+ # Background tasks
82
+ self._tasks: list[asyncio.Task] = []
83
+
84
+ # Parser and serializer
85
+ self._parser = JsonRpcParser()
86
+ self._serializer = JsonRpcSerializer()
87
+
88
+ # Track if we're in the middle of shutdown
89
+ self._shutting_down = False
90
+
91
+ logger.info("StdioInterface initialized", queue_size=queue_size)
92
+
93
+ @property
94
+ def is_running(self) -> bool:
95
+ """Check if the interface is currently running."""
96
+ return self._running
97
+
98
+ @property
99
+ def pending_incoming(self) -> int:
100
+ """Get the number of messages waiting to be processed."""
101
+ return self._incoming_queue.qsize()
102
+
103
+ @property
104
+ def pending_outgoing(self) -> int:
105
+ """Get the number of messages waiting to be sent."""
106
+ return self._outgoing_queue.qsize()
107
+
108
+ def set_message_handler(
109
+ self,
110
+ handler: Callable[[JsonRpcMessage], Awaitable[Optional[JsonRpcMessage]]]
111
+ ) -> None:
112
+ """Set or update the message handler.
113
+
114
+ Args:
115
+ handler: Async callback for handling incoming messages.
116
+ Should return a JsonRpcMessage for requests or None for notifications.
117
+ """
118
+ self._message_handler = handler
119
+ logger.debug("Message handler updated")
120
+
121
+ async def start(self) -> None:
122
+ """Start the stdio interface and begin processing messages.
123
+
124
+ This method:
125
+ 1. Sets up stdin/stdout streams
126
+ 2. Installs signal handlers for graceful shutdown
127
+ 3. Starts background tasks for reading, writing, and processing
128
+
129
+ Raises:
130
+ RuntimeError: If interface is already running
131
+ """
132
+ if self._running:
133
+ raise RuntimeError("StdioInterface is already running")
134
+
135
+ logger.info("Starting StdioInterface")
136
+ self._running = True
137
+ self._shutting_down = False
138
+ self._shutdown_event.clear()
139
+ self._started_event.clear()
140
+
141
+ # Set up stdin/stdout streams
142
+ await self._setup_streams()
143
+
144
+ # Install signal handlers for graceful shutdown
145
+ self._install_signal_handlers()
146
+
147
+ # Start background tasks
148
+ self._tasks = [
149
+ asyncio.create_task(self._read_loop(), name="stdio-read"),
150
+ asyncio.create_task(self._write_loop(), name="stdio-write"),
151
+ asyncio.create_task(self._process_loop(), name="stdio-process"),
152
+ ]
153
+
154
+ # Signal that we're fully started
155
+ self._started_event.set()
156
+
157
+ logger.info("StdioInterface started successfully")
158
+
159
+ async def wait_until_started(self, timeout: Optional[float] = None) -> bool:
160
+ """Wait until the interface has fully started.
161
+
162
+ Args:
163
+ timeout: Maximum time to wait in seconds (None for no timeout)
164
+
165
+ Returns:
166
+ True if started successfully, False if timeout occurred
167
+ """
168
+ try:
169
+ if timeout is not None:
170
+ await asyncio.wait_for(self._started_event.wait(), timeout=timeout)
171
+ else:
172
+ await self._started_event.wait()
173
+ return True
174
+ except asyncio.TimeoutError:
175
+ return False
176
+
177
+ async def stop(self) -> None:
178
+ """Stop the stdio interface and cleanup resources.
179
+
180
+ This method:
181
+ 1. Signals shutdown to all background tasks
182
+ 2. Drains pending outgoing messages
183
+ 3. Waits for tasks to complete gracefully
184
+ 4. Closes streams and cleans up resources
185
+ """
186
+ if not self._running:
187
+ logger.warning("StdioInterface is not running")
188
+ return
189
+
190
+ if self._shutting_down:
191
+ logger.warning("StdioInterface is already shutting down")
192
+ return
193
+
194
+ logger.info("Stopping StdioInterface")
195
+ self._shutting_down = True
196
+ self._running = False
197
+ self._shutdown_event.set()
198
+
199
+ # Drain pending outgoing messages before stopping
200
+ await self._drain_outgoing_queue()
201
+
202
+ # Cancel all background tasks
203
+ for task in self._tasks:
204
+ if not task.done():
205
+ task.cancel()
206
+
207
+ # Wait for tasks to complete (with timeout)
208
+ if self._tasks:
209
+ try:
210
+ await asyncio.wait_for(
211
+ asyncio.gather(*self._tasks, return_exceptions=True),
212
+ timeout=SHUTDOWN_TIMEOUT
213
+ )
214
+ except asyncio.TimeoutError:
215
+ logger.warning("Timeout waiting for tasks to complete")
216
+
217
+ # Close streams
218
+ await self._close_streams()
219
+
220
+ # Clear queues
221
+ self._clear_queues()
222
+
223
+ self._tasks.clear()
224
+ self._shutting_down = False
225
+ logger.info("StdioInterface stopped")
226
+
227
+ async def _drain_outgoing_queue(self) -> None:
228
+ """Drain and send all pending outgoing messages before shutdown.
229
+
230
+ This ensures that any queued responses are sent before the interface
231
+ stops, preventing message loss.
232
+ """
233
+ if not self._stdout_writer or self._outgoing_queue.empty():
234
+ return
235
+
236
+ logger.info("Draining outgoing message queue", pending=self._outgoing_queue.qsize())
237
+
238
+ try:
239
+ async with asyncio.timeout(DRAIN_TIMEOUT):
240
+ while not self._outgoing_queue.empty():
241
+ try:
242
+ message = self._outgoing_queue.get_nowait()
243
+ line = self._serializer.serialize(message)
244
+ self._stdout_writer.write(line.encode('utf-8'))
245
+ await self._stdout_writer.drain()
246
+ logger.debug("Drained message", message_id=message.id)
247
+ except asyncio.QueueEmpty:
248
+ break
249
+ except asyncio.TimeoutError:
250
+ logger.warning("Timeout draining outgoing queue", remaining=self._outgoing_queue.qsize())
251
+
252
+ async def send_message(self, message: JsonRpcMessage) -> None:
253
+ """Send a JSON-RPC message to stdout.
254
+
255
+ Messages are queued and sent asynchronously in FIFO order,
256
+ maintaining message ordering as required by the MCP protocol.
257
+
258
+ Args:
259
+ message: JsonRpcMessage to send
260
+
261
+ Raises:
262
+ RuntimeError: If interface is not running
263
+ asyncio.QueueFull: If the outgoing queue is full
264
+ """
265
+ if not self._running and not self._shutting_down:
266
+ raise RuntimeError("StdioInterface is not running")
267
+
268
+ try:
269
+ # Use put_nowait to avoid blocking if queue is full
270
+ self._outgoing_queue.put_nowait(message)
271
+ logger.debug("Queued outgoing message", message_id=message.id)
272
+ except asyncio.QueueFull:
273
+ logger.error("Outgoing queue is full, message dropped", message_id=message.id)
274
+ raise
275
+
276
+ async def receive_message(self, timeout: Optional[float] = None) -> Optional[JsonRpcMessage]:
277
+ """Receive a JSON-RPC message from stdin.
278
+
279
+ Args:
280
+ timeout: Maximum time to wait in seconds (None for no timeout)
281
+
282
+ Returns:
283
+ JsonRpcMessage received from stdin, or None if timeout occurred
284
+
285
+ Raises:
286
+ RuntimeError: If interface is not running
287
+ """
288
+ if not self._running:
289
+ raise RuntimeError("StdioInterface is not running")
290
+
291
+ try:
292
+ if timeout is not None:
293
+ message = await asyncio.wait_for(
294
+ self._incoming_queue.get(),
295
+ timeout=timeout
296
+ )
297
+ else:
298
+ message = await self._incoming_queue.get()
299
+ logger.debug("Received message from queue", message_id=message.id)
300
+ return message
301
+ except asyncio.TimeoutError:
302
+ return None
303
+
304
+ async def wait_for_shutdown(self) -> None:
305
+ """Wait for shutdown signal.
306
+
307
+ This method blocks until the interface is signaled to shut down,
308
+ useful for keeping the main process alive.
309
+ """
310
+ await self._shutdown_event.wait()
311
+
312
+ # ========================================================================
313
+ # Private Methods - Stream Setup
314
+ # ========================================================================
315
+
316
+ async def _setup_streams(self) -> None:
317
+ """Set up async stdin/stdout streams."""
318
+ loop = asyncio.get_event_loop()
319
+
320
+ # Create stdin reader
321
+ self._stdin_reader = asyncio.StreamReader()
322
+ protocol = asyncio.StreamReaderProtocol(self._stdin_reader)
323
+ await loop.connect_read_pipe(lambda: protocol, sys.stdin)
324
+
325
+ # Create stdout writer
326
+ transport, protocol = await loop.connect_write_pipe(
327
+ asyncio.streams.FlowControlMixin,
328
+ sys.stdout
329
+ )
330
+ self._stdout_writer = asyncio.StreamWriter(
331
+ transport, protocol, None, loop
332
+ )
333
+
334
+ logger.debug("Stdio streams set up successfully")
335
+
336
+ async def _close_streams(self) -> None:
337
+ """Close stdin/stdout streams."""
338
+ if self._stdout_writer:
339
+ try:
340
+ self._stdout_writer.close()
341
+ await self._stdout_writer.wait_closed()
342
+ except Exception as e:
343
+ logger.error(f"Error closing stdout writer: {e}")
344
+
345
+ self._stdin_reader = None
346
+ self._stdout_writer = None
347
+ logger.debug("Stdio streams closed")
348
+
349
+ # ========================================================================
350
+ # Private Methods - Signal Handling
351
+ # ========================================================================
352
+
353
+ def _install_signal_handlers(self) -> None:
354
+ """Install signal handlers for graceful shutdown."""
355
+ loop = asyncio.get_event_loop()
356
+
357
+ for sig in (signal.SIGTERM, signal.SIGINT):
358
+ try:
359
+ loop.add_signal_handler(
360
+ sig,
361
+ lambda s=sig: asyncio.create_task(self._handle_signal(s))
362
+ )
363
+ logger.debug(f"Installed signal handler for {sig.name}")
364
+ except NotImplementedError:
365
+ # Signal handlers not supported on this platform (e.g., Windows)
366
+ logger.warning(f"Signal handlers not supported for {sig.name}")
367
+
368
+ async def _handle_signal(self, sig: signal.Signals) -> None:
369
+ """Handle shutdown signals.
370
+
371
+ Args:
372
+ sig: Signal that was received
373
+ """
374
+ logger.info(f"Received signal {sig.name}, initiating shutdown")
375
+ await self.stop()
376
+
377
+ # ========================================================================
378
+ # Private Methods - Background Tasks
379
+ # ========================================================================
380
+
381
+ async def _read_loop(self) -> None:
382
+ """Background task for reading messages from stdin.
383
+
384
+ Continuously reads newline-delimited JSON-RPC messages from stdin
385
+ and queues them for processing. Maintains message ordering by
386
+ processing messages in the order they are received.
387
+ """
388
+ logger.info("Read loop started")
389
+
390
+ try:
391
+ while self._running and self._stdin_reader:
392
+ try:
393
+ # Read a line from stdin (newline-delimited)
394
+ line_bytes = await self._stdin_reader.readline()
395
+
396
+ if not line_bytes:
397
+ # EOF reached - initiate graceful shutdown
398
+ logger.info("EOF reached on stdin, initiating shutdown")
399
+ asyncio.create_task(self.stop())
400
+ break
401
+
402
+ # Decode and parse the message
403
+ line = line_bytes.decode('utf-8')
404
+
405
+ # Skip empty lines
406
+ if not line.strip():
407
+ continue
408
+
409
+ try:
410
+ message = self._parser.parse(line)
411
+ try:
412
+ self._incoming_queue.put_nowait(message)
413
+ logger.debug("Read message from stdin", message_id=message.id, method=message.method)
414
+ except asyncio.QueueFull:
415
+ logger.error("Incoming queue full, message dropped", message_id=message.id)
416
+ except InvalidJsonRpcError as e:
417
+ logger.error("Invalid JSON-RPC message", error=str(e))
418
+ # Continue reading despite parse errors
419
+
420
+ except asyncio.CancelledError:
421
+ logger.info("Read loop cancelled")
422
+ break
423
+ except Exception as e:
424
+ logger.error("Error in read loop", error=str(e), exc_info=True)
425
+ # Continue reading despite errors
426
+ await asyncio.sleep(0.1)
427
+
428
+ finally:
429
+ logger.info("Read loop stopped")
430
+
431
+ async def _write_loop(self) -> None:
432
+ """Background task for writing messages to stdout.
433
+
434
+ Continuously processes the outgoing message queue and writes
435
+ messages to stdout in newline-delimited format. Messages are
436
+ sent in FIFO order to maintain message ordering.
437
+ """
438
+ logger.info("Write loop started")
439
+
440
+ try:
441
+ while self._running:
442
+ try:
443
+ # Wait for a message with timeout to allow checking shutdown
444
+ try:
445
+ message = await asyncio.wait_for(
446
+ self._outgoing_queue.get(),
447
+ timeout=1.0
448
+ )
449
+ except asyncio.TimeoutError:
450
+ continue
451
+
452
+ if not self._stdout_writer:
453
+ logger.error("Stdout writer not available")
454
+ continue
455
+
456
+ # Serialize and write the message
457
+ line = self._serializer.serialize(message)
458
+ self._stdout_writer.write(line.encode('utf-8'))
459
+ await self._stdout_writer.drain()
460
+
461
+ logger.debug("Wrote message to stdout", message_id=message.id)
462
+
463
+ except asyncio.CancelledError:
464
+ logger.info("Write loop cancelled")
465
+ break
466
+ except Exception as e:
467
+ logger.error("Error in write loop", error=str(e), exc_info=True)
468
+ await asyncio.sleep(0.1)
469
+
470
+ finally:
471
+ logger.info("Write loop stopped")
472
+
473
+ async def _process_loop(self) -> None:
474
+ """Background task for processing incoming messages.
475
+
476
+ Continuously processes incoming messages using the message handler
477
+ and queues responses for sending. Handles both requests (which expect
478
+ responses) and notifications (which don't).
479
+
480
+ Messages are processed in FIFO order to maintain message ordering
481
+ as required by the MCP protocol.
482
+ """
483
+ logger.info("Process loop started")
484
+
485
+ if not self._message_handler:
486
+ logger.warning("No message handler set, messages will not be processed")
487
+ # Still need to consume messages to prevent queue from filling up
488
+ while self._running:
489
+ try:
490
+ try:
491
+ message = await asyncio.wait_for(
492
+ self._incoming_queue.get(),
493
+ timeout=1.0
494
+ )
495
+ logger.warning("Message received but no handler set", message_id=message.id)
496
+ except asyncio.TimeoutError:
497
+ continue
498
+ except asyncio.CancelledError:
499
+ break
500
+ return
501
+
502
+ try:
503
+ while self._running:
504
+ try:
505
+ # Wait for a message with timeout to allow checking shutdown
506
+ try:
507
+ message = await asyncio.wait_for(
508
+ self._incoming_queue.get(),
509
+ timeout=1.0
510
+ )
511
+ except asyncio.TimeoutError:
512
+ continue
513
+
514
+ # Process the message using the handler
515
+ try:
516
+ response = await self._message_handler(message)
517
+
518
+ # Queue the response for sending (if any)
519
+ # Notifications (messages without ID) don't get responses
520
+ if response is not None:
521
+ try:
522
+ self._outgoing_queue.put_nowait(response)
523
+ logger.debug("Processed message, queued response",
524
+ request_id=message.id, response_id=response.id)
525
+ except asyncio.QueueFull:
526
+ logger.error("Outgoing queue full, response dropped",
527
+ message_id=message.id)
528
+ elif message.is_notification():
529
+ logger.debug("Processed notification", method=message.method)
530
+ else:
531
+ logger.debug("Handler returned no response", message_id=message.id)
532
+
533
+ except Exception as e:
534
+ logger.error("Error processing message",
535
+ message_id=message.id, error=str(e), exc_info=True)
536
+ # Create error response for requests (not notifications)
537
+ if message.id is not None:
538
+ from .jsonrpc import create_error_response
539
+ error_response = create_error_response(
540
+ request_id=message.id,
541
+ code=-32603,
542
+ message=f"Internal error: {str(e)}"
543
+ )
544
+ try:
545
+ self._outgoing_queue.put_nowait(error_response)
546
+ except asyncio.QueueFull:
547
+ logger.error("Outgoing queue full, error response dropped",
548
+ message_id=message.id)
549
+
550
+ except asyncio.CancelledError:
551
+ logger.info("Process loop cancelled")
552
+ break
553
+ except Exception as e:
554
+ logger.error("Error in process loop", error=str(e), exc_info=True)
555
+ await asyncio.sleep(0.1)
556
+
557
+ finally:
558
+ logger.info("Process loop stopped")
559
+
560
+ # ========================================================================
561
+ # Private Methods - Utilities
562
+ # ========================================================================
563
+
564
+ def _clear_queues(self) -> None:
565
+ """Clear all message queues."""
566
+ # Clear incoming queue
567
+ while not self._incoming_queue.empty():
568
+ try:
569
+ self._incoming_queue.get_nowait()
570
+ except asyncio.QueueEmpty:
571
+ break
572
+
573
+ # Clear outgoing queue
574
+ while not self._outgoing_queue.empty():
575
+ try:
576
+ self._outgoing_queue.get_nowait()
577
+ except asyncio.QueueEmpty:
578
+ break
579
+
580
+ logger.debug("Message queues cleared")