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.
mcp_proxy/proxy.py ADDED
@@ -0,0 +1,586 @@
1
+ """Main MCP Proxy orchestration.
2
+
3
+ This module provides the main McpProxy class that orchestrates all components:
4
+ - Stdio interface for communication with Kiro
5
+ - Protocol translator for stdio <-> HTTP conversion
6
+ - Authenticated HTTP client for backend communication
7
+ - Configuration management
8
+ - Graceful startup and shutdown
9
+ - Signal handling and cleanup
10
+
11
+ Requirements satisfied:
12
+ - 3.1: Perform OAuth DCR with OAuth provider on startup
13
+ - 2.1: Establish connections to HTTP MCP server
14
+ """
15
+
16
+ import asyncio
17
+ import logging
18
+ import signal
19
+ from typing import Optional
20
+
21
+ from .auth.manager import AuthenticationManagerImpl
22
+ from .config.manager import ConfigurationManagerImpl
23
+ from .http.authenticated_client import AuthenticatedHttpClient
24
+ from .http.client import HttpClientImpl
25
+ from .models import JsonRpcMessage, ProxyConfig
26
+ from .translator.translator import ProtocolTranslatorImpl
27
+ from .stdio.interface import StdioInterface
28
+ from .exceptions import McpProxyError, ConfigurationError
29
+ from .logging_config import get_logger, configure_logging
30
+
31
+ logger = get_logger(__name__)
32
+
33
+
34
+ class McpProxy:
35
+ """Main MCP Proxy orchestrator.
36
+
37
+ This class brings together all components to create a complete proxy that:
38
+ - Presents a stdio MCP interface to Kiro
39
+ - Translates between stdio and HTTP MCP protocols
40
+ - Manages OAuth DCR authentication
41
+ - Handles HTTP communication with backend MCP server
42
+ - Provides graceful startup and shutdown
43
+
44
+ The proxy operates as follows:
45
+ 1. Load configuration from environment variables
46
+ 2. Initialize authentication manager (performs DCR)
47
+ 3. Create HTTP client with authentication
48
+ 4. Set up protocol translator
49
+ 5. Start stdio interface
50
+ 6. Process messages bidirectionally
51
+ 7. Handle shutdown signals gracefully
52
+
53
+ Attributes:
54
+ config: Proxy configuration
55
+ is_running: Whether the proxy is currently running
56
+ """
57
+
58
+ def __init__(self, config: Optional[ProxyConfig] = None):
59
+ """Initialize the MCP Proxy.
60
+
61
+ Args:
62
+ config: Optional proxy configuration. If not provided,
63
+ configuration will be loaded from environment variables.
64
+ """
65
+ self._config = config
66
+ self._running = False
67
+ self._shutdown_event = asyncio.Event()
68
+
69
+ # Components (initialized during startup)
70
+ self._config_manager: Optional[ConfigurationManagerImpl] = None
71
+ self._auth_manager: Optional[AuthenticationManagerImpl] = None
72
+ self._http_client: Optional[HttpClientImpl] = None
73
+ self._authenticated_client: Optional[AuthenticatedHttpClient] = None
74
+ self._translator: Optional[ProtocolTranslatorImpl] = None
75
+ self._stdio_interface: Optional[StdioInterface] = None
76
+
77
+ logger.info("McpProxy instance created")
78
+
79
+ @property
80
+ def config(self) -> Optional[ProxyConfig]:
81
+ """Get the current configuration."""
82
+ return self._config
83
+
84
+ @property
85
+ def is_running(self) -> bool:
86
+ """Check if the proxy is currently running."""
87
+ return self._running
88
+
89
+ async def start(self) -> None:
90
+ """Start the MCP Proxy.
91
+
92
+ This method performs the complete startup sequence:
93
+ 1. Load configuration (if not provided)
94
+ 2. Set up logging
95
+ 3. Initialize authentication manager (performs DCR)
96
+ 4. Create and initialize HTTP client
97
+ 5. Set up protocol translator
98
+ 6. Start stdio interface
99
+ 7. Install signal handlers
100
+
101
+ Raises:
102
+ ConfigurationError: If configuration is invalid
103
+ AuthenticationError: If OAuth DCR fails
104
+ RuntimeError: If proxy is already running
105
+ """
106
+ if self._running:
107
+ raise RuntimeError("McpProxy is already running")
108
+
109
+ logger.info("Starting MCP Proxy")
110
+
111
+ try:
112
+ # Step 1: Load configuration
113
+ await self._load_configuration()
114
+
115
+ # Step 2: Set up logging
116
+ self._setup_logging()
117
+
118
+ # Step 3: Initialize authentication manager
119
+ await self._initialize_authentication()
120
+
121
+ # Step 4: Create and initialize HTTP client
122
+ await self._initialize_http_client()
123
+
124
+ # Step 5: Set up protocol translator
125
+ self._initialize_translator()
126
+
127
+ # Step 6: Start stdio interface
128
+ await self._start_stdio_interface()
129
+
130
+ # Step 7: Install signal handlers
131
+ self._install_signal_handlers()
132
+
133
+ self._running = True
134
+ self._shutdown_event.clear()
135
+
136
+ logger.info("MCP Proxy started successfully")
137
+
138
+ except Exception as e:
139
+ logger.error(f"Failed to start MCP Proxy: {e}", exc_info=True)
140
+ # Clean up any partially initialized components
141
+ await self._cleanup()
142
+ raise
143
+
144
+ async def stop(self) -> None:
145
+ """Stop the MCP Proxy gracefully.
146
+
147
+ This method performs graceful shutdown:
148
+ 1. Signal shutdown to all components
149
+ 2. Stop stdio interface (drains pending messages)
150
+ 3. Close authenticated HTTP client
151
+ 4. Close authentication manager
152
+ 5. Clean up resources
153
+ """
154
+ if not self._running:
155
+ logger.warning("McpProxy is not running")
156
+ return
157
+
158
+ logger.info("Stopping MCP Proxy")
159
+ self._running = False
160
+ self._shutdown_event.set()
161
+
162
+ try:
163
+ # Stop stdio interface first (drains pending messages)
164
+ if self._stdio_interface:
165
+ await self._stdio_interface.stop()
166
+
167
+ # Close authenticated HTTP client
168
+ if self._authenticated_client:
169
+ await self._authenticated_client.close()
170
+
171
+ # Close authentication manager
172
+ if self._auth_manager:
173
+ await self._auth_manager.close()
174
+
175
+ # Clean up remaining resources
176
+ await self._cleanup()
177
+
178
+ logger.info("MCP Proxy stopped successfully")
179
+
180
+ except Exception as e:
181
+ logger.error(f"Error during shutdown: {e}", exc_info=True)
182
+ raise
183
+
184
+ async def run(self) -> None:
185
+ """Run the proxy until shutdown signal is received.
186
+
187
+ This method starts the proxy and waits for a shutdown signal.
188
+ It's the main entry point for running the proxy as a standalone process.
189
+
190
+ Usage:
191
+ proxy = McpProxy()
192
+ await proxy.run()
193
+ """
194
+ await self.start()
195
+
196
+ try:
197
+ # Wait for shutdown signal
198
+ logger.info("MCP Proxy running, waiting for shutdown signal")
199
+ await self._shutdown_event.wait()
200
+ except KeyboardInterrupt:
201
+ logger.info("Received keyboard interrupt")
202
+ finally:
203
+ await self.stop()
204
+
205
+ async def wait_for_shutdown(self) -> None:
206
+ """Wait for shutdown signal.
207
+
208
+ This method blocks until the proxy is signaled to shut down.
209
+ Useful for keeping the main process alive while the proxy runs.
210
+ """
211
+ await self._shutdown_event.wait()
212
+
213
+ # ========================================================================
214
+ # Private Methods - Initialization
215
+ # ========================================================================
216
+
217
+ async def _load_configuration(self) -> None:
218
+ """Load configuration from environment variables if not provided."""
219
+ if self._config is None:
220
+ logger.info("Loading configuration from environment variables")
221
+ self._config_manager = ConfigurationManagerImpl()
222
+ self._config = await self._config_manager.load()
223
+ logger.info("Configuration loaded successfully")
224
+ else:
225
+ logger.info("Using provided configuration")
226
+
227
+ # Log configuration (excluding sensitive data)
228
+ logger.info(
229
+ "Configuration: mcp_server_url=%s, oauth_provider_url=%s, "
230
+ "client_name=%s, scopes=%s, connection_timeout=%s, "
231
+ "retry_attempts=%s, log_level=%s",
232
+ self._config.mcp_server_url,
233
+ self._config.oauth_provider_url,
234
+ self._config.client_name,
235
+ self._config.scopes,
236
+ self._config.connection_timeout,
237
+ self._config.retry_attempts,
238
+ self._config.log_level,
239
+ )
240
+
241
+ def _setup_logging(self) -> None:
242
+ """Set up logging based on configuration."""
243
+ if self._config:
244
+ configure_logging(self._config.log_level)
245
+ logger.info(f"Logging configured with level: {self._config.log_level}")
246
+
247
+ async def _initialize_authentication(self) -> None:
248
+ """Initialize authentication manager and perform DCR.
249
+
250
+ This satisfies Requirement 3.1: Perform OAuth DCR on startup.
251
+ """
252
+ if not self._config:
253
+ raise ConfigurationError("Configuration not loaded")
254
+
255
+ logger.info("Initializing authentication manager")
256
+ self._auth_manager = AuthenticationManagerImpl(self._config)
257
+
258
+ # Initialize performs DCR and obtains initial token
259
+ await self._auth_manager.initialize()
260
+
261
+ logger.info("Authentication manager initialized successfully")
262
+
263
+ async def _initialize_http_client(self) -> None:
264
+ """Initialize HTTP client with authentication.
265
+
266
+ This satisfies Requirement 2.1: Establish connections to HTTP MCP server.
267
+ """
268
+ if not self._config or not self._auth_manager:
269
+ raise RuntimeError("Configuration or authentication not initialized")
270
+
271
+ logger.info("Initializing HTTP client")
272
+
273
+ # Create retry configuration
274
+ from .http.client import RetryConfig
275
+ retry_config = RetryConfig(
276
+ max_retries=self._config.retry_attempts,
277
+ max_delay=self._config.max_backoff_seconds,
278
+ )
279
+
280
+ # Create base HTTP client
281
+ self._http_client = HttpClientImpl(
282
+ base_url=str(self._config.mcp_server_url),
283
+ timeout=self._config.connection_timeout,
284
+ retry_config=retry_config,
285
+ )
286
+
287
+ # Wrap with authentication
288
+ self._authenticated_client = AuthenticatedHttpClient(
289
+ http_client=self._http_client,
290
+ auth_manager=self._auth_manager,
291
+ )
292
+
293
+ # Initialize authenticated client (ensures token is ready)
294
+ await self._authenticated_client.initialize()
295
+
296
+ logger.info("HTTP client initialized successfully")
297
+
298
+ def _initialize_translator(self) -> None:
299
+ """Initialize protocol translator."""
300
+ if not self._config:
301
+ raise RuntimeError("Configuration not initialized")
302
+
303
+ logger.info("Initializing protocol translator")
304
+
305
+ self._translator = ProtocolTranslatorImpl(
306
+ base_url=str(self._config.mcp_server_url)
307
+ )
308
+
309
+ logger.info("Protocol translator initialized successfully")
310
+
311
+ async def _start_stdio_interface(self) -> None:
312
+ """Start stdio interface with message handler."""
313
+ logger.info("Starting stdio interface")
314
+
315
+ # Create stdio interface with message handler
316
+ self._stdio_interface = StdioInterface(
317
+ message_handler=self._handle_stdio_message
318
+ )
319
+
320
+ # Start the interface
321
+ await self._stdio_interface.start()
322
+
323
+ # Wait for interface to be fully started
324
+ started = await self._stdio_interface.wait_until_started(timeout=5.0)
325
+ if not started:
326
+ raise RuntimeError("Stdio interface failed to start within timeout")
327
+
328
+ logger.info("Stdio interface started successfully")
329
+
330
+ # ========================================================================
331
+ # Private Methods - Message Handling
332
+ # ========================================================================
333
+
334
+ async def _handle_stdio_message(
335
+ self,
336
+ message: JsonRpcMessage
337
+ ) -> Optional[JsonRpcMessage]:
338
+ """Handle incoming stdio message from Kiro.
339
+
340
+ This is the main message processing pipeline:
341
+ 1. Translate stdio message to HTTP request
342
+ 2. Send HTTP request via authenticated client
343
+ 3. Translate HTTP response back to stdio message
344
+ 4. Return stdio response
345
+
346
+ Args:
347
+ message: JSON-RPC message from Kiro
348
+
349
+ Returns:
350
+ JSON-RPC response message, or None for notifications
351
+ """
352
+ if not self._translator or not self._authenticated_client:
353
+ logger.error(
354
+ "Components not initialized",
355
+ has_translator=self._translator is not None,
356
+ has_authenticated_client=self._authenticated_client is not None,
357
+ )
358
+ return self._create_error_response(
359
+ message.id,
360
+ -32603,
361
+ "Internal error: proxy not fully initialized"
362
+ )
363
+
364
+ try:
365
+ # Handle notifications (no response expected)
366
+ if message.is_notification():
367
+ logger.debug(
368
+ "Received notification",
369
+ method=message.method,
370
+ )
371
+ # For now, we don't forward notifications to backend
372
+ # This could be extended to support server-to-client notifications
373
+ return None
374
+
375
+ # Translate stdio request to HTTP
376
+ logger.debug(
377
+ "Translating stdio request to HTTP",
378
+ method=message.method,
379
+ id=message.id,
380
+ has_params=message.params is not None,
381
+ )
382
+ http_request = await self._translator.translate_stdio_to_http(message)
383
+
384
+ # Send HTTP request via authenticated client
385
+ logger.debug(
386
+ "Sending HTTP request",
387
+ http_method=http_request.method,
388
+ url=http_request.url,
389
+ session_id=http_request.session_id,
390
+ )
391
+ http_response = await self._authenticated_client.send_request(http_request)
392
+
393
+ # Translate HTTP response back to stdio
394
+ logger.debug(
395
+ "Translating HTTP response to stdio",
396
+ status=http_response.status,
397
+ content_type=http_response.content_type,
398
+ body_length=len(http_response.body) if http_response.body else 0,
399
+ )
400
+ stdio_response = await self._translator.translate_http_to_stdio(
401
+ http_response,
402
+ stdio_request_id=message.id
403
+ )
404
+
405
+ # Mark correlation as completed
406
+ if message.id is not None:
407
+ try:
408
+ self._translator.mark_correlation_completed(message.id)
409
+ except Exception as e:
410
+ logger.warning(
411
+ "Failed to mark correlation completed",
412
+ error=str(e),
413
+ message_id=message.id,
414
+ )
415
+
416
+ logger.info(
417
+ "Message processed successfully",
418
+ method=message.method,
419
+ request_id=message.id,
420
+ response_has_result=stdio_response.result is not None,
421
+ response_has_error=stdio_response.error is not None,
422
+ )
423
+ return stdio_response
424
+
425
+ except McpProxyError as e:
426
+ logger.error(
427
+ "Proxy error handling message",
428
+ error=str(e),
429
+ error_type=type(e).__name__,
430
+ message_id=message.id,
431
+ method=message.method,
432
+ has_details=hasattr(e, 'details') and e.details is not None,
433
+ exc_info=True,
434
+ )
435
+
436
+ # Mark correlation as failed
437
+ if message.id is not None:
438
+ try:
439
+ self._translator.mark_correlation_failed(message.id)
440
+ except Exception:
441
+ pass
442
+
443
+ # Return error response
444
+ from .exceptions import to_jsonrpc_error
445
+ error_obj = to_jsonrpc_error(e)
446
+ return self._create_error_response(
447
+ message.id,
448
+ error_obj["code"],
449
+ error_obj["message"],
450
+ error_obj.get("data")
451
+ )
452
+
453
+ except Exception as e:
454
+ logger.error(
455
+ "Unexpected error handling message",
456
+ error=str(e),
457
+ error_type=type(e).__name__,
458
+ message_id=message.id,
459
+ method=message.method,
460
+ exc_info=True,
461
+ )
462
+
463
+ # Mark correlation as failed
464
+ if message.id is not None:
465
+ try:
466
+ self._translator.mark_correlation_failed(message.id)
467
+ except Exception:
468
+ pass
469
+
470
+ # Return generic error response
471
+ return self._create_error_response(
472
+ message.id,
473
+ -32603,
474
+ f"Internal error: {str(e)}"
475
+ )
476
+
477
+ def _create_error_response(
478
+ self,
479
+ request_id: Optional[int | str],
480
+ code: int,
481
+ message: str,
482
+ data: Optional[any] = None
483
+ ) -> JsonRpcMessage:
484
+ """Create a JSON-RPC error response.
485
+
486
+ Args:
487
+ request_id: Request ID to respond to
488
+ code: JSON-RPC error code
489
+ message: Error message
490
+ data: Optional additional error data
491
+
492
+ Returns:
493
+ JSON-RPC error response
494
+ """
495
+ from .models import JsonRpcError
496
+
497
+ return JsonRpcMessage(
498
+ jsonrpc="2.0",
499
+ id=request_id,
500
+ error=JsonRpcError(
501
+ code=code,
502
+ message=message,
503
+ data=data
504
+ )
505
+ )
506
+
507
+ # ========================================================================
508
+ # Private Methods - Signal Handling
509
+ # ========================================================================
510
+
511
+ def _install_signal_handlers(self) -> None:
512
+ """Install signal handlers for graceful shutdown."""
513
+ loop = asyncio.get_event_loop()
514
+
515
+ for sig in (signal.SIGTERM, signal.SIGINT):
516
+ try:
517
+ loop.add_signal_handler(
518
+ sig,
519
+ lambda s=sig: asyncio.create_task(self._handle_signal(s))
520
+ )
521
+ logger.debug(f"Installed signal handler for {sig.name}")
522
+ except NotImplementedError:
523
+ # Signal handlers not supported on this platform (e.g., Windows)
524
+ logger.warning(f"Signal handlers not supported for {sig.name}")
525
+
526
+ async def _handle_signal(self, sig: signal.Signals) -> None:
527
+ """Handle shutdown signals.
528
+
529
+ Args:
530
+ sig: Signal that was received
531
+ """
532
+ logger.info(f"Received signal {sig.name}, initiating shutdown")
533
+ await self.stop()
534
+
535
+ # ========================================================================
536
+ # Private Methods - Cleanup
537
+ # ========================================================================
538
+
539
+ async def _cleanup(self) -> None:
540
+ """Clean up resources during shutdown or after failed startup."""
541
+ logger.debug("Cleaning up resources")
542
+
543
+ # Close components in reverse order of initialization
544
+ if self._stdio_interface and self._stdio_interface.is_running:
545
+ try:
546
+ await self._stdio_interface.stop()
547
+ except Exception as e:
548
+ logger.error(f"Error stopping stdio interface: {e}")
549
+
550
+ if self._authenticated_client:
551
+ try:
552
+ await self._authenticated_client.close()
553
+ except Exception as e:
554
+ logger.error(f"Error closing authenticated client: {e}")
555
+
556
+ if self._auth_manager:
557
+ try:
558
+ await self._auth_manager.close()
559
+ except Exception as e:
560
+ logger.error(f"Error closing auth manager: {e}")
561
+
562
+ # Clear references
563
+ self._stdio_interface = None
564
+ self._authenticated_client = None
565
+ self._http_client = None
566
+ self._auth_manager = None
567
+ self._translator = None
568
+
569
+ logger.debug("Cleanup completed")
570
+
571
+ # ========================================================================
572
+ # Context Manager Support
573
+ # ========================================================================
574
+
575
+ async def __aenter__(self) -> "McpProxy":
576
+ """Async context manager entry."""
577
+ await self.start()
578
+ return self
579
+
580
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
581
+ """Async context manager exit."""
582
+ await self.stop()
583
+
584
+ def __repr__(self) -> str:
585
+ """String representation of the proxy."""
586
+ return f"McpProxy(running={self._running}, config={self._config is not None})"
@@ -0,0 +1,31 @@
1
+ """Stdio interface module for MCP Proxy.
2
+
3
+ This module handles stdio communication with Kiro using JSON-RPC protocol.
4
+ """
5
+
6
+ from .interface import StdioInterface, DEFAULT_QUEUE_SIZE, SHUTDOWN_TIMEOUT, DRAIN_TIMEOUT
7
+ from .jsonrpc import (
8
+ JsonRpcParser,
9
+ JsonRpcSerializer,
10
+ MessageIdGenerator,
11
+ MessageCorrelationTracker,
12
+ create_request,
13
+ create_response,
14
+ create_notification,
15
+ create_error_response,
16
+ )
17
+
18
+ __all__ = [
19
+ "StdioInterface",
20
+ "DEFAULT_QUEUE_SIZE",
21
+ "SHUTDOWN_TIMEOUT",
22
+ "DRAIN_TIMEOUT",
23
+ "JsonRpcParser",
24
+ "JsonRpcSerializer",
25
+ "MessageIdGenerator",
26
+ "MessageCorrelationTracker",
27
+ "create_request",
28
+ "create_response",
29
+ "create_notification",
30
+ "create_error_response",
31
+ ]